From: "Confusion (Ivo Wever) via ruby-core" Date: 2026-10-23T14:31:08+00:00 Subject: [ruby-core:127221] [Ruby Bug#22467] IO#readpartial can raise EFAULT during GC.compact in another thread Issue #22467 has been reported by Confusion (Ivo Wever). ---------------------------------------- Bug #22467: IO#readpartial can raise EFAULT during GC.compact in another thread https://bugs.ruby-lang.org/issues/22467 * Author: Confusion (Ivo Wever) * Status: Open * ruby -v: ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [x86_64-linux * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- 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): ``` ruby # 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. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/