Bug #22382
openIO#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 5 hours ago
Thank you for this bug report. I have a fix here.