Bug #22445
Updated by dug (Doug E) about 18 hours ago
SUBJECT: On macOS, a child of Kernel#fork can abort with "[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)"
TRACKER: Bug ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]
---
On macOS, `Kernel#fork` can abort the child with `[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)` when another thread is waiting for the fork lock at the moment of the fork. getaddrinfo helper threads take that lock for reading (Feature #20590), and since [Bug #22406] getnameinfo helper threads do too, so a process that resolves names in background threads while it forks loses a child now and then.
## Reproduction
```ruby
require "socket"
# Threads that keep resolving names, so that one is often waiting for the
# fork lock while a fork holds it.
6.times { Thread.new { loop { Addrinfo.getaddrinfo("localhost", 80, :INET, :STREAM) } } }
aborted = 0
1000.times do
_, status = Process.wait2(fork { exit!(0) })
aborted += 1 unless status.success?
end
puts "#{RUBY_DESCRIPTION}: #{aborted} of 1000 children aborted"
```
On macOS 27.0.1 (arm64), every aborted child printed `[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)`, with `rb_thread_reset_fork_lock` under `rb_fork_ruby` in its backtrace:
```
ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [arm64-darwin23]: 3 of 1000 children aborted
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]: 4 of 1000 children aborted
```
No network is needed: `localhost` comes from /etc/hosts.
## Analysis
`rb_fork_ruby` takes the fork lock for writing, forks, and the child calls `rb_thread_release_fork_lock()` and then `rb_thread_reset_fork_lock()`, which destroys the lock and initializes it again. A thread that calls `rb_thread_prevent_fork()` while the fork holds the lock blocks in `pthread_rwlock_rdlock()`. That thread does not exist in the child, but macOS's lock counts its waiters, and the child's copy still counts it, so `pthread_rwlock_destroy()` returns EBUSY and `rb_bug_errno()` aborts. `pthread_rwlock_init()` returns EBUSY as well.
This C program shows it without Ruby. With a thread waiting for the read lock at the fork, both calls fail in the child; without one, both succeed. Assigning `PTHREAD_RWLOCK_INITIALIZER` works either way.
```c
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER;
static void *
reader(void *arg)
{
pthread_rwlock_rdlock(&lock); // blocks: main holds the write lock
pthread_rwlock_unlock(&lock);
return NULL;
}
int
main(void)
{
pthread_t th;
pthread_rwlock_wrlock(&lock);
pthread_create(&th, NULL, reader, NULL);
usleep(100000); // let it block in rdlock
if (fork() == 0) {
int u = pthread_rwlock_unlock(&lock);
int d = pthread_rwlock_destroy(&lock);
int i = pthread_rwlock_init(&lock, NULL);
printf("unlock=%s destroy=%s init=%s\n", u ? strerror(u) : "ok", d ? strerror(d) : "ok", i ? strerror(i) : "ok");
fflush(stdout);
_exit(0);
}
wait(NULL);
pthread_rwlock_unlock(&lock);
pthread_join(th, NULL);
}
```
```
unlock=ok destroy=Resource busy init=Resource busy
```
glibc's `pthread_rwlock_destroy()` does not check the lock, so Linux is not affected.
c8155822c4 ([Bug #21959]) hit the same problem with `rb_internal_thread_event_hooks_rw_lock` and reinitializes it in the child by assigning `PTHREAD_RWLOCK_INITIALIZER`; it was backported to 3.4 and 4.0. The fork lock was added with `rb_thread_reset_fork_lock()` in 63cbe3f6ac (Feature #20590), so 3.4 and later are affected.
## Fix
I will open a pull request for this ticket. In the child, it resets the fork lock by assigning `PTHREAD_RWLOCK_INITIALIZER` and no longer unlocks the child's copy first. Assigning after the unlock is not enough on macOS: with a waiter counted, the unlock goes into the kernel to wake it, and the state that leaves for the lock's address survives the assignment, so a later waiter in the child can sleep forever although no thread holds the lock. FreeBSD already skips that unlock in the child (dc8c382266), and on glibc it leaves the lock write-locked anyway, so the child can skip it on every platform.