Project

General

Profile

Actions

Bug #22445

closed

On macOS, a child of Kernel#fork can abort with "[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)"

Bug #22445: On macOS, a child of Kernel#fork can abort with "[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)"

Added by dug (Doug E) about 16 hours ago. Updated about 2 hours ago.

Status:
Closed
Assignee:
-
Target version:
-
ruby -v:
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin23]
[ruby-core:127118]

Description

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

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.

#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.

Updated by dug (Doug E) about 16 hours ago Actions #1

  • Description updated (diff)

Updated by dugg (Doug E) about 3 hours ago Actions #3

  • Status changed from Open to Closed

Applied in changeset git|e06b9da3f20455ba4cff99a7821f013b7bdde5de.


Reset the fork lock in the child without unlocking it

A thread that is waiting for the fork lock when another thread forks
exists only in the parent, but on macOS the child's copy of the lock
still counts it. After the child unlocks its copy,
pthread_rwlock_destroy() and pthread_rwlock_init() fail with EBUSY, so
the child aborts with
"[BUG] pthread_rwlock_destroy: Resource busy (EBUSY)". Lookup threads
wait for the lock whenever a fork holds it, so a process that resolves
names in background threads while it forks loses a child now and then.

Reset the child's copy by assigning PTHREAD_RWLOCK_INITIALIZER, as
rb_internal_thread_event_hooks_rw_lock_atfork() does for its lock, and
do not unlock it first. With a waiter counted, macOS's unlock enters the
kernel to wake it, and the state that leaves behind 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 skipped
the unlock in the child (dc8c382266), and on glibc the child's unlock
does not release the write lock anyway.

[Bug #22445]

Updated by byroot (Jean Boussier) about 2 hours ago Actions #4

  • Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: WONTFIX, 3.4: REQUIRED, 4.0: REQUIRED
Actions

Also available in: PDF Atom