Project

General

Profile

Actions

Bug #22467

open

IO#readpartial can raise EFAULT during GC.compact in another thread

Bug #22467: IO#readpartial can raise EFAULT during GC.compact in another thread

Added by Confusion (Ivo Wever) about 14 hours ago.

Status:
Open
Assignee:
-
Target version:
-
ruby -v:
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [x86_64-linux
[ruby-core:127221]

Description

This was observed in a test suite that calls GC.compact before forking. A thread doing socket.readpartial(512) in a loop died with Errno::EFAULT: Bad address, in ~10% of runs. After dropping the GC.compact, the error was no longer produced.

Concise reproduction script (usually fails within 10 compactions):

# Makes IO#readpartial raise Errno::EFAULT when GC.compact runs in another thread.
require 'socket'

socket, peer = UNIXSocket.pair
delays, delays_in = IO.pipe
# The data must come from another process: no thread in this one runs while it compacts.
writer = fork { loop { sleep(delays.read(1).ord / 1000.0); peer.write('x') } }

buffers = Queue.new
reader = Thread.new { loop { socket.readpartial(512, buffers.pop) } }
reader.report_on_exception = false

begin
  100.times do
    # Sparse pages of 640-byte slots with one pinned object each (identity hash keys are pinned).
    # Compaction empties and locks such pages; the buffer is one of the pinned objects.
    strings = Array.new(200_000) { 'y' * 500 }
    pinned = {}.compare_by_identity
    movable = strings.each_slice(20).map {|slice| pinned[slice[0]] = true; slice[10] }
    buffers << strings[100_000]
    strings = nil
    sleep 0.01                      # the reader drains the socket and waits in poll(2)
    delays_in.write(rand(30).chr)   # the next byte arrives somewhere during the compaction
    GC.compact
    reader.join(0)
  end
  puts "#{RUBY_DESCRIPTION}: no error"
rescue => e
  puts "#{RUBY_DESCRIPTION}: #{e.class}: #{e.message}"
ensure
  Process.kill(:KILL, writer)
end

The following explanation by Claude is outside of my expertise to verify:

This is the read-side counterpart of #20169. The read path still passes RSTRING_PTR(str) of a possibly embedded string to read(2) without the GVL. If another thread is running GC.compact and has locked that string's heap page with mprotect(PROT_NONE), the kernel cannot copy the data and read(2) fails with EFAULT.

In io_getpartial, iis.buf = RSTRING_PTR(str) is passed to io_read_memory_locktmp. internal_read_func then runs read(2), and on EAGAIN it waits in poll(2) and retries read(2), all inside the blocking region. So if the socket is idle when compaction starts and data arrives while the buffer's page is locked, the read fails.

... sysread, read_nonblock, write_nonblock and syswrite look like they also pass the string's memory to a syscall without the GVL, so they are probably affected too.

No data to display

Actions

Also available in: PDF Atom