Bug #22382
closedIO#write of a shareable String from multiple Ractors causes use-after-free
Description
When several Ractors IO#write the same shareable, heap-allocated (non-embedded) String at the same moment, Ruby frees the String's buffer while the String is still alive. A later GC sweep double-frees it and aborts.
Reproduction
# frozen_string_literal: true
require "tmpdir"
dir = Dir.mktmpdir
TRIES = 300
1.upto(TRIES) do |try|
$stderr.puts "try #{try} of #{TRIES}"
str = Ractor.make_shareable(Random.bytes(512 * 1024))
# Control: one prior write from a single Ractor prevents the crash.
File.binwrite(File.join(dir, "first"), str) if ENV["WRITE_FIRST"]
go = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.02
12.times.map do |n|
Ractor.new(str, File.join(dir, "w#{n}"), go) do |s, path, at|
Thread.pass until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= at # start together
File.binwrite(path, s)
end
end.each(&:join)
GC.start
end
puts "finished #{TRIES} tries without crashing"
try 9 of 300
<internal:gc>:44: [BUG] Aborted at 0x000000018e37e5e8
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]
...
libruby.4.0.dylib(_rb_gc_impl_free+0x50)
libruby.4.0.dylib(_rb_gc_obj_free+0x1e8)
libruby.4.0.dylib(_gc_sweep_plane+0x150)
macOS reports the abort as POINTER_BEING_FREED_WAS_NOT_ALLOCATED. Crashes 5 runs out of 5. With WRITE_FIRST=1 (each String written once, from one Ractor, before the race) it finishes without crashing.
Cause
The crash goes away when each String is written once from a single Ractor first (WRITE_FIRST=1), which points at rb_str_tmp_frozen_no_embed_acquire (io.c:2038): on a String's first write it hands the String's buffer to a new String and re-points the String at it (string.c:1573).
The same function was changed for fstrings in #21671.
A similar issue around fstrings was resolved in #21671.
Files
Updated by peterzhu2118 (Peter Zhu) about 2 months ago
Thank you for this bug report. I have a fix here.
Updated by peterzhu2118 (Peter Zhu) about 1 month ago
- Status changed from Open to Closed
Applied in changeset git|0093a0268f6de4467f58e7ee920fb1913ef6bbb7.
Fix race condition in rb_str_tmp_frozen_no_embed_acquire
[Bug #22382]
rb_str_tmp_frozen_no_embed_acquire is not Ractor safe for the non-embedded
case because it modifies the orig string. This can cause a race condition
if multiple Ractors are executing the same code path for the same string.
Since commit 9144c914a1a51176d461c06e11bc78723c2c24cb, strings that are
pinned are not re-embedded, so we can safely return the original string
if it is frozen and not embedded.
The following script reproduces the crash:
# frozen_string_literal: true
require "tmpdir"
dir = Dir.mktmpdir
TRIES = 300
1.upto(TRIES) do |try|
$stderr.puts "try #{try} of #{TRIES}"
str = Ractor.make_shareable(Random.bytes(512 * 1024))
# Control: one prior write from a single Ractor prevents the crash.
File.binwrite(File.join(dir, "first"), str) if ENV["WRITE_FIRST"]
go = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.02
12.times.map do |n|
Ractor.new(str, File.join(dir, "w#{n}"), go) do |s, path, at|
Thread.pass until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= at # start together
File.binwrite(path, s)
end
end.each(&:join)
GC.start
end
puts "finished #{TRIES} tries without crashing"