[ruby-core:126921] [Ruby Bug#22406] Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock
Issue #22406 has been reported by dug (dug e). ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406 * Author: dug (dug e) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). I've opened a pull request: https://github.com/ruby/ruby/pull/19209 Rather than either of the directions in the description, on glibc each lookup thread does the detach itself with `res_nclose(&_res)` before it leaves `rb_thread_prevent_fork`, then clears `nscount` so that glibc's thread-exit cleanup skips it. With the patch built into 4.0.7, the reproduction script went from 20 hung children in 60,000 forks to 0, and a variant that connects with `TCPSocket.new` (the Happy Eyeballs path) went from 4 to 0. The patch also applies cleanly to `ruby_4_0` and `ruby_3_4`. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119308 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh). Thank you for the detailed report. I believe your analysis is correct. Neither of the two directions in the description looked easy to me. A thread pool could never shrink, since exiting a thread is exactly the dangerous step. Joining the helpers means they can no longer be detached, and on timeout or cancellation nobody is left to join them. As for the PR: as I understand it, each lookup thread releases its resolver state itself with `res_nclose(&_res)` while it still holds the fork lock, and then sets `_res.nscount = 0` as a hack to make glibc's thread-exit cleanup skip that release. Nothing guarantees that glibc keeps checking `nscount` there, which bothers me a bit, but if it fixes the problem, it seems like a reasonable thing to do for now. @byroot @shioimm What do you think? ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119309 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by byroot (Jean Boussier).
What do you think?
I'm trying to figure out whether this is even public API, as in, will this break under us in a couple years. ```c if (_res.nscount > 0) { res_nclose(&_res); _res.nscount = 0; // to make the thread exit skip the detach _res.options = 0; } ``` And I found an Arch Linux manpage that seem to indicate that `_res` is deprecated? https://man.archlinux.org/man/core/man-pages/res_nclose.3.en ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119310 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh). I agree that this is not an API I'd like to depend on. That said, glibc does seem to intend to keep `_res` itself compatible. The 2.26 NEWS says:
For backward compatibility, applications that directly modify the ‘_res’ global object are still limited to six search domains.
https://sourceware.org/git/?p=glibc.git;a=blob;f=NEWS;hb=glibc-2.26#l41 What is not guaranteed at all is that `_res.nscount == 0` keeps working as an "uninitialized" marker, so that part is fragile. But I can't think of a better solution either, as long as glibc does not officially support `getaddrinfo()` across `fork()`. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119311 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E).
I'm trying to figure out whether this is even public API, as in, will this break under us in a couple years.
The manpage marks the `_res` global [deprecated](https://man7.org/linux/man-pages/man3/resolver.3.html#SYNOPSIS). According to its [description](https://man7.org/linux/man-pages/man3/resolver.3.html#DESCRIPTION), older functions such as `res_init()` and `res_query()` keep their state in `_res`, which makes them non-thread-safe, while the newer `res_n*()` functions take a `res_state`. The patch only calls one of the newer functions, `res_nclose()`, which the manpage lists as [MT-Safe](https://man7.org/linux/man-pages/man3/resolver.3.html#ATTRIBUTES). It does pass it `&_res`, which in glibc is the calling thread's own state ([`#define _res (*__res_state())`](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv.h#L162-L164)), and the same state glibc's [thread exit closes](https://github.com/bminor/glibc/blob/04e750e75b73957cf1c791535a3f4319534a52f...). @mame is right that glibc doesn't promise its thread exit will keep skipping a state whose `nscount` is 0. That check has been in the thread-exit cleanup since at least 2.17 (2012), first in [`res_init.c`](https://github.com/bminor/glibc/blob/glibc-2.17/resolv/res_init.c#L642-L646) and since 2.26 in [`res-close.c`](https://github.com/bminor/glibc/blob/glibc-2.26/resolv/res-close.c#L133-L137), so it predates the lock itself, which arrived in 2.26 with [f30a54b21b83](https://github.com/bminor/glibc/commit/f30a54b21b83f254533c59ca72ad17af5249c...). It is also [unchanged in current master](https://github.com/bminor/glibc/blob/04e750e75b73957cf1c791535a3f4319534a52f...). If glibc did drop it, the patch would fail harmlessly. `res_nclose()` already [clears the state's index](https://github.com/bminor/glibc/blob/04e750e75b73957cf1c791535a3f4319534a52f...) when it detaches, so a second detach at thread exit would look up `0 ^ INDEX_MAGIC`, and [`decrement_at_index`](https://github.com/bminor/glibc/blob/04e750e75b73957cf1c791535a3f4319534a52f...) ignores an index that far out of range. The race would come back, but nothing else would change.
Joining the helpers means they can no longer be detached, and on timeout or cancellation nobody is left to join them.
Each helper could add itself to a list just before it releases the fork lock, whether or not its caller is still waiting. Fork would then join everything on the list after taking the write lock, since those threads are already exiting, and each new helper would reap the list when it starts, which keeps it short. That would work on any libc, at the cost of more code in `thread_pthread.c` and `raddrinfo.c`. I'm happy to rework the PR that way if you'd prefer it. Longer term, glibc could handle this lock around fork the way `fork()` already handles the NSS database lock, [before forking](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L62-L63) and [in the child](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L100-L101). I didn't find an existing glibc bug for it, and can file one if that seems useful. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119313 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by byroot (Jean Boussier).
rework the PR that way if you'd prefer it.
I personally prefer that option, as adding code that depends on things marked as deprecated scares me a bit. But if @mame feels strongly otherwise I'm ok with it. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119319 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh). I'm worried that the join-list approach may leak pthreads. Is that okay? To make the helper pthreads joinable, we have to stop detaching them. Then a helper's resources are not released until someone joins it. What concerns me is an app that resolves names repeatedly (possibly in parallel) without ever forking. A pthread is created for every lookup, and it would be a problem if they were only joined when a fork happens. I guess the idea is that each new lookup reaps the worker pthreads left by earlier lookups. But if names are resolved in parallel, one worker is created per lookup, and if the app then stops resolving names right after that, those workers' resources would stay unreclaimed while the process keeps running. To deal with this, I think we would need something that periodically checks whether the workers have finished and joins them, for example in Ruby's timer thread or at interrupt-check points. However, doing that inside socket.so does not look easy. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119329 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by byroot (Jean Boussier).
it would be a problem if they were only joined when a fork happens.
In my mind we're regularly do non blocking join, e.g. whenever we start a new name resolution, or finish one. So it would be self cleaning. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119330 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh).
whenever we start a new name resolution, or finish one
I don't think we can join when one finishes. Only the worker itself knows when it has exited, and it cannot join itself. So the last workers stay unjoined until the next resolution starts, unless we poll for them periodically, for example at interrupt checks. Also, nobu and ko1 discussed non-blocking join (`pthread_tryjoin_np`) before, but it is a GNU extension and not portable. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119331 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E).
I don't think we can join when one finishes. Only the worker itself knows when it has exited, and it cannot join itself.
The waiting caller can join it, though. When the result arrives, the worker is moments from exiting, so the thread that was waiting for it can call `pthread_join` right there, while it still runs without the GVL. That leaves only workers whose caller gave up on them, after a timeout or an interrupt, to be joined later by the next lookup or by fork.
nobu and ko1 discussed non-blocking join (`pthread_tryjoin_np`) before, but it is a GNU extension and not portable.
A blocking `pthread_join` should be enough, because a worker would only go on the list once its lookup has finished. Joining it then waits for nothing but the rest of its own thread exit. So the threads left unjoined at any moment would be the ones whose callers gave up since the last lookup or fork, which should be few. If that sounds acceptable, I can put this version up as a second PR so the two can be compared. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119332 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by byroot (Jean Boussier).
If that sounds acceptable, I can put this version up as a second PR so the two can be compared.
Yes please do. I think your proposal is fine, but it's always easier to reason about it with the code. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119333 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh).
That leaves only workers whose caller gave up on them, after a timeout or an interrupt, to be joined later by the next lookup or by fork.
This is exactly the possible leak I'm worried about. If a batch job does name resolution in parallel with Threads and something goes wrong with DNS, I think all the workers could leak. That said, in that case the process would usually retry to resolve the names again, or just fail and exit. Maybe I'm worrying too much. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119334 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). 👋 @byroot @mame, here is the second version: https://github.com/ruby/ruby/pull/19242
If a batch job does name resolution in parallel with Threads and something goes wrong with DNS, I think all the workers could leak.
🧵 In this version a worker doesn't wait for a new lookup to be joined. When its `getaddrinfo(3)` returns, each worker takes the oldest of 8 slots and joins the worker that held it, whether or not its own caller is still waiting. So when every caller in a batch gives up, the workers still join each other as their lookups end, and at most 8 are left for the next lookup or fork. 🧪 I tried that case with `resolv.conf` pointing at a nameserver that never answers: 10 batches of 20 threads each called `Addrinfo.getaddrinfo` with `timeout: 0.1`. Three seconds after each batch, VmSize was 64 MB above master's every time, the 8 thread stacks the slots can hold. 🔄 A few changes from my last note: * The caller no longer joins its own worker. That made every lookup wait for its worker's exit, 5 to 42 µs longer per lookup in my benchmark, and the workers already join each other. * A single slot wasn't enough. Each worker then waited for the one before it to exit, and under a steady stream of lookups on Linux those waits piled up into hundreds of live threads. With 8 slots the thread count matches master's. * `Process.daemon` on the BSDs forks with `daemon(3)`, so the PR now takes the fork lock around it as well. Otherwise the daemon would inherit the registered workers. 📊 The PR has the numbers. It is heavily AI-assisted, but I have gone over it, and I (of course) take responsibility for it. 🙇♂️ I'm humbled by the attention and thoughtful input that you've both given thus far. I'm hoping I can further trouble you for a review. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119338 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by ko1 (Koichi Sasada). surveyed by claude code: `getnameinfo` has the same problem, and neither PR covers it. glibc's `getnameinfo` also attaches the per-thread resolver state (`_res.nscount` becomes 1 even when the name is found in /etc/hosts). The `do_getnameinfo` thread runs outside `rb_thread_prevent_fork`, and it is still created detached in #19242. I used gdb to stop an exiting `do_getnameinfo` thread right after it takes `lock` in `__resolv_conf_detach` (called from `__res_thread_freeres`), and then forked. On master, #19209 and #19242 alike, the child could not resolve `localhost`. Without the breakpoint, the child resolves. (x86_64, glibc 2.43) So `getnameinfo` needs the same handling: call it within `rb_thread_prevent_fork`, and with #19242, create the thread joinable and call `rb_thread_register_exiting()` at its end. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119392 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). @ko1 Thank you for checking getnameinfo, and you're right that it belongs here. I've added it to https://github.com/ruby/ruby/pull/19242: * The getnameinfo thread now runs within `rb_thread_prevent_fork`, is created joinable and calls `rb_thread_register_exiting()` at its end, as getaddrinfo threads do. Where getnameinfo runs without a helper thread (`GETADDRINFO_IMPL == 1`), it is called within `rb_thread_prevent_fork` too. * With every lookup thread joinable, `raddrinfo_pthread_create` no longer needs its detached parameter, so a second commit drops it. * A third adds `test_daemon_after_getnameinfo`, like the existing test for getaddrinfo. With four threads looping `Socket.getnameinfo(["AF_INET", 80, "127.0.0.1"])` while the main thread forks 20,000 times, 12 to 14 children per run could not resolve `localhost` on master, and none with the change (three runs each, aarch64 Linux, glibc 2.41). The cost is that fork now also waits for a reverse lookup in progress, as it already does for a forward one. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119396 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). @mame @byroot @ko1 Thank you for the time you've given this ticket, and for the thoughtful feedback here and on the pull requests. https://github.com/ruby/ruby/pull/19242 now uses a single slot, as @byroot and Luke Gruber suggested on the PR. A finishing worker swaps itself into the slot with an atomic exchange and joins the worker it displaced, and fork joins whatever is left. So at most one finished worker stays unjoined instead of 8: in the batch case @mame described, where every caller gives up, VmSize now stays 8 MB above master's instead of 64 MB. I've closed the timer-thread follow-up, https://github.com/ruby/ruby/pull/19263. The cost is that under heavy load the exits chain again, with each finishing worker waiting for the one before it to exit, and fork waits for the chain. On aarch64 Linux, forks took as long as on master with up to about 11,000 lookups a second, and right after bursts of up to 1,024 concurrent lookups. The exits chained only when 8 threads resolved `localhost` from /etc/hosts back to back, which master does at 20,000 to 33,000 lookups a second, and there the median fork took about 40 ms instead of under 1 ms. The PR has the numbers and the scripts. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119415 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). Hi @mame, could I trouble you for a review of [PR#19242](https://github.com/ruby/ruby/pull/19242) ? @byroot has requested your input. 🙇♂️ ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119491 * Author: dug (Doug E) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). While preparing the backports I found one kind of child that still inherits the slot of exiting lookup threads: a child that an extension forks with `fork(2)` and then passes to `rb_thread_atfork()`, as the C API asks. `rb_thread_acquire_fork_lock()` never runs for that fork, so the child's first lookup thread to finish joins a thread that exists only in the parent. On macOS the child aborts with `[BUG] pthread_join: No such process (ESRCH)`, and on glibc it can hang at its next fork or at exit. I didn't find a maintained gem that forks this way. https://github.com/ruby/ruby/pull/19364 empties the slot in `thread_sched_atfork()`. The same PR also proposes a fix for #22445, a macOS abort in `rb_thread_reset_fork_lock()` that `getnameinfo` threads can now trigger as well as `getaddrinfo` threads. If the PR is accepted, I'd be happy to open backport PRs for 4.0 and 3.4. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119522 * Author: dug (Doug E) * Status: Closed * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: DONTNEED, 3.4: REQUIRED, 4.0: REQUIRED ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by byroot (Jean Boussier).
a child that an extension forks with fork(2) and then passes to rb_thread_atfork(), as the C API asks
I remember this being discussed a while ago and I believe that the conclusion was that forking from C wasn't supported. I'm a bit surprised to see the documentation of `rb_thread_atfork` now. But either way:
The same PR also proposes a fix for #22445,
Please don't batch multiple fixes together, that makes the review and backporting process painful. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119525 * Author: dug (Doug E) * Status: Closed * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: DONTNEED, 3.4: REQUIRED, 4.0: REQUIRED ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by mame (Yusuke Endoh). Like @byroot, I don't think Ruby explicitly supports extensions calling fork(2) directly. In practice, since 3.4 an extension cannot fork safely anyway: it cannot take the fork lock before fork(2) or reset it in the child (`rb_thread_acquire_fork_lock` and `rb_thread_reset_fork_lock` are not exported). So I don't think we need to worry about this case. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119530 * Author: dug (Doug E) * Status: Closed * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: DONTNEED, 3.4: REQUIRED, 4.0: REQUIRED ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by dug (Doug E). Thanks, that makes sense, and sorry for batching the two fixes. byroot dropped the commit for extensions that call `fork(2)` from https://github.com/ruby/ruby/pull/19364 before merging it, so the PR fixed only #22445. 🙇♂️ ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119535 * Author: dug (Doug E) * Status: Closed * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: DONTNEED, 3.4: REQUIRED, 4.0: REQUIRED ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
Issue #22406 has been updated by nagachika (Tomoyuki Chikanaga). Backport changed from 3.3: DONTNEED, 3.4: REQUIRED, 4.0: DONE to 3.3: DONTNEED, 3.4: DONE, 4.0: DONE Merged into `ruby_3_4` at https://github.com/ruby/ruby/commit/56c3e7ea7b8530d6ee28904c8148026cc236ba9a. ---------------------------------------- Bug #22406: Process.fork can leave the child unable to resolve hostnames: getaddrinfo helper threads take glibc's resolver lock after releasing the fork lock https://bugs.ruby-lang.org/issues/22406#change-119559 * Author: dug (Doug E) * Status: Closed * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] * Backport: 3.3: DONTNEED, 3.4: DONE, 4.0: DONE ---------------------------------------- On Linux with glibc, a process forked while another thread's `getaddrinfo` helper pthread is exiting can inherit glibc's resolver-configuration lock in the locked state. Every hostname lookup in the child then blocks forever. Feature #20590 keeps fork out of the `getaddrinfo(3)` call itself, but the helper releases that protection before its thread exit takes the same lock. This is not the macOS problem in #21790, #21876 and #21969. Those are a crash in Apple's resolver that every child hits after any lookup in the parent. This one is on Linux with glibc. It is a race with another thread's lookup, and the child waits on a lock forever rather than crashing. ## Reproduce process Reproduced with each of these, in the official `ruby:<version>-slim` images (Debian, glibc 2.41, aarch64): ``` ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [aarch64-linux] ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [aarch64-linux] ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux] ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [aarch64-linux] ``` master ([dbabf7d789](https://github.com/ruby/ruby/tree/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66)) has the same code: [`rb_thread_prevent_fork`](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/t...) still releases the fork lock before the helper returns, and the helpers are still wrapped at [raddrinfo.c L552](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...) and [L3193](https://github.com/ruby/ruby/blob/dbabf7d78994c6ab50cee24fa85bf7d68a14fe66/e...). No network is needed. The background lookups and the probe both resolve `localhost` from `/etc/hosts`. ```ruby require "socket" # Threads that keep resolving a hostname, as an HTTP poller in the parent would. 4.times do Thread.new do loop do Addrinfo.getaddrinfo("localhost", 80, nil, :STREAM) Thread.pass end end end sleep 0.5 hung = 0 20_000.times do reader, writer = IO.pipe pid = fork do reader.close lookup = Thread.new { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } writer.write(lookup.join(2) ? "ok" : "hung") exit!(0) end writer.close hung += 1 if reader.read == "hung" reader.close Process.wait(pid) end puts "#{RUBY_DESCRIPTION}: #{hung} of 20000 children could not resolve localhost" ``` As a control, change the background threads to resolve `"127.0.0.1"`. That address never reaches `getaddrinfo(3)`, so no helper thread is started. ## Result Runs on aarch64 Linux in Docker: | Ruby | Background threads resolve | Children that could not resolve, per run of 20,000 forks | | --- | --- | --- | | 4.0.7 | `localhost` | 6, 3 | | 4.0.7 | `127.0.0.1` (control) | 0 | | 3.4.11 | `localhost` | 3, 2 | | 3.4.11 | `127.0.0.1` (control) | 0 | | 4.0.6 | `localhost` | 10, 3, 8, 2 | | 4.0.6 | `127.0.0.1` (control) | 0 | | 3.4.10 | `localhost` | 1, 4, 4, 1 | | 3.4.10 | `127.0.0.1` (control) | 0 | This is the stack of the stuck helper thread in a hung child, from `eu-stack` with `libc6-dbg` installed (3.4.10, glibc 2.41): ``` #0 __lll_lock_wait_private #1 get_locked_global #2 __resolv_conf_get_current #3 __res_vinit #4 maybe_init #5 __resolv_context_get #6 gethostbyname2_r@@GLIBC_2.17 #7 getaddrinfo #8 do_getaddrinfo #9 rb_thread_prevent_fork #10 start_thread #11 thread_start ``` The futex it waits on resolves to libc's static `lock` (libc+0x1b7dd8 in that build), the lock `get_locked_global` takes. ## Expected result Every child resolves `localhost`, as every child does in the control runs. Feature #20590 made fork wait for in-flight `getaddrinfo` calls so that a child cannot inherit a resolver lock held by another thread. A child should not inherit one from the part of a helper thread's life that comes after the call either. ## Analysis - Each helper thread's start routine runs `getaddrinfo(3)` inside `rb_thread_prevent_fork`, which holds `rb_thread_fork_rw_lock` for reading. `rb_fork_ruby` takes that lock for writing. - The read lock is released as soon as `getaddrinfo` returns. Then the helper thread exits, and glibc's thread exit takes the resolver lock: - `start_thread` calls `__libc_thread_freeres`, - which calls `__res_thread_freeres` and then `__res_iclose(&_res, true)`, - which calls `__resolv_conf_detach`, - which calls `get_locked_global`, locking the static `lock` in `resolv/resolv_conf.c`. - glibc's `fork` resets the malloc, stdio, NSS and dynamic loader locks in the child, but not this one. So a fork that runs after `rb_thread_release_fork_lock()` in the helper, and before the helper's thread exit releases the resolver lock, copies that lock held by a thread that does not exist in the child. The only resolver callers in the reproduction are Ruby's own helper threads, and the fork lock keeps fork out of their `getaddrinfo` calls. So the lock the child inherited was held by a helper thread that was exiting. The window is probably hit more often than its size suggests (inference). When `Process.fork` arrives while a helper is inside `getaddrinfo`, `rb_thread_acquire_fork_lock` blocks. The helper's `pthread_rwlock_unlock` then wakes it just as that same helper moves on to exit and take the resolver lock. Ruby keeps the waiting Ruby thread interruptible, so `Timeout` and `resolv_timeout` still fire. Lookups of IP literals still work, because they never reach `getaddrinfo(3)`. So in practice every new connection by hostname times out for the life of the process. Source: - Ruby at v3_4_11; v4.0.7 and master have the same structure, see the master links above: - [raddrinfo.c L525](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L525) and [L3161](https://github.com/ruby/ruby/blob/v3_4_11/ext/socket/raddrinfo.c#L3161) - [thread_pthread.c L3329-L3338](https://github.com/ruby/ruby/blob/v3_4_11/thread_pthread.c#L3329-L3338) - [process.c L4236](https://github.com/ruby/ruby/blob/v3_4_11/process.c#L4236) - glibc 2.41, the version in the reproduction. 2.35 has the same code on these paths. - [nptl/pthread_create.c L459](https://github.com/bminor/glibc/blob/glibc-2.41/nptl/pthread_create.c#L459): `start_thread` calls `__libc_thread_freeres`. - [malloc/thread-freeres.c L35](https://github.com/bminor/glibc/blob/glibc-2.41/malloc/thread-freeres.c#L35): that calls `__res_thread_freeres`. - [resolv/res-close.c L129-L142](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L129-L142) and [L117](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/res-close.c#L117): `__res_iclose(&_res, true)` calls `__resolv_conf_detach`. - [resolv/resolv_conf.c L81](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L81) defines `lock`. [L648](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L648) takes it at thread exit, and [L130](https://github.com/bminor/glibc/blob/glibc-2.41/resolv/resolv_conf.c#L130) is where the child waits. - [posix/fork.c L88-L108](https://github.com/bminor/glibc/blob/glibc-2.41/posix/fork.c#L88-L108): the locks fork resets in the child. ## Impact This affects preforking servers whose parent process runs background threads that connect by hostname. We found it in a Pitchfork deployment. A feature-flag SDK in the parent polls an HTTP endpoint every 30 seconds, and roughly one in tens of thousands of forked workers comes up unable to open any connection by hostname. Pitchfork's documentation for `Pitchfork.prevent_fork` warns about `getaddrinfo(3)` in background threads. ## Possible directions These are not patches, just the options I considered: - **Wait for helpers to finish exiting.** Keep fork out until helper threads have exited, not only until they have finished `getaddrinfo`. For example, fork could join helpers that have returned but not yet exited, though today the helpers are detached. - **Reuse resolver threads.** Keep a small pool instead of exiting after each lookup, so the detach that runs at thread exit happens only at process exit. glibc could also reset this lock in the child, as it does the NSS locks. But glibc's position is that only async-signal-safe functions are supported in the child of a multithreaded process. ## Workaround - Avoid hostname lookups in background threads of a process that forks, or wrap them so the fork waits until the thread is fully idle. - To contain the damage, check resolution in the child after fork and exit if it hangs. For example, run `Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM)` in a thread and join it with a short timeout. cc @byroot (Feature #20590), @mame (Feature #19965), @shioimm (the Happy Eyeballs `getaddrinfo` helper) -- https://bugs.ruby-lang.org/
participants (6)
-
byroot (Jean Boussier) -
dug (Doug E) -
dug (dug e) -
ko1 (Koichi Sasada) -
mame (Yusuke Endoh) -
nagachika (Tomoyuki Chikanaga)