Ractor: keep the bytes a moved string still shares

Wiping the body of a hollowed-out object (556296cbe0) is wrong for a string
whose bytes another string reads in place:

    r = Ractor.new { Ractor.receive }
    str = "x" * 100
    str.instance_variable_set(:@iv, [])   # unshareable, so it is moved
    str.freeze
    dup = str.dup                          # reads str's bytes in place
    r.send(str, move: true)
    dup                                    #=> "\0\0\0..." (was "xxx...")

str_replace_shared_without_enc() shares instead of copying whenever the target's
embedded capacity is too small, and the shared bytes are the source's own slot
when the source is embedded.  The sharer keeps the root alive and reads that slot
for as long as it lives, so the move must leave it as it is.  Skip the wipe for an
embedded shared root; every other case (a private heap buffer, a plain embedded
string) keeps it.

The default GC embeds strings up to a few hundred bytes and mmtk embeds any size,
so this is reachable with an ordinary dup of a frozen string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Koichi SasadaandClaude Opus 5 committed 2026-08-05 07:19:26 +09:00
1 parent ae2411c8f2
commit f4e50b9993
4 files changed
+36 -1
Notes: git 2026-08-04 22:19:57 +00:00

No files matched your search

+1
View File
@@ -93,6 +93,7 @@ void rb_str_make_embedded(VALUE);
VALUE rb_str_upto_each(VALUE, VALUE, int, int (*each)(VALUE, VALUE), VALUE);
size_t rb_str_size_as_embedded(VALUE);
bool rb_str_reembeddable_p(VALUE);
bool rb_str_embedded_shared_root_p(VALUE);
VALUE rb_str_upto_endless_each(VALUE, int (*each)(VALUE, VALUE), VALUE);
VALUE rb_str_with_debug_created_info(VALUE, VALUE, int);
VALUE rb_str_frozen_bare_string(VALUE);
+9 -1
View File
@@ -17,6 +17,7 @@
#include "internal/rational.h"
#include "internal/struct.h"
#include "internal/st.h"
#include "internal/string.h"
#include "internal/thread.h"
#include "variable.h"
#include "yjit.h"
@@ -2124,6 +2125,11 @@ move_leave(VALUE obj, struct obj_traverse_replace_data *data)
VALUE flags = T_OBJECT | FL_FREEZE | (RBASIC(obj)->flags & FL_PROMOTED);
shape_id_t shape_id = (RBASIC_SHAPE_ID(obj) & SHAPE_ID_CAPACITY_MASK) | ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_ROBJECT | SHAPE_ID_FL_FROZEN;
// A copy-on-write sharer reads its bytes straight out of an embedded root's slot
// (String#dup of a frozen string), and it outlives the move, so that body has to
// survive as it is.
bool wipe_body = !(RB_TYPE_P(obj, T_STRING) && rb_str_embedded_shared_root_p(obj));
// Avoid mutations using bind_call, etc.
size_t slot_size = rb_gc_obj_slot_size(obj);
MEMZERO((char *)obj, char, sizeof(struct RBasic));
@@ -2134,7 +2140,9 @@ move_leave(VALUE obj, struct obj_traverse_replace_data *data)
// but C code that held the object from before the move still reads it with its
// old type (an Array iteration in progress, the RMatch capa behind $~): a zeroed
// body makes those reads see an empty object instead of stale internals.
MEMZERO((char *)obj + sizeof(struct RBasic), char, slot_size - sizeof(struct RBasic));
if (wipe_body) {
MEMZERO((char *)obj + sizeof(struct RBasic), char, slot_size - sizeof(struct RBasic));
}
// The husk keeps its original (larger) slot, so give it a field-less shape
// sized to that slot; otherwise compaction's slot_size == shape_slot_size
+8
View File
@@ -229,6 +229,14 @@ rb_str_reembeddable_p(VALUE str)
return !FL_TEST(str, STR_NOFREE|STR_SHARED_ROOT|STR_SHARED);
}
/* True when other strings read this string's bytes out of its own slot, so the slot
* contents must stay valid for as long as the object does. */
bool
rb_str_embedded_shared_root_p(VALUE str)
{
return STR_EMBED_P(str) && FL_TEST(str, STR_SHARED_ROOT);
}
static inline size_t
rb_str_embed_size(long capa, long termlen)
{
+18
View File
@@ -491,4 +491,22 @@ class TestRactor < Test::Unit::TestCase
r.value
RUBY
end
# String#dup of a frozen string shares the original's bytes, and for an embedded
# string those bytes live in its slot. Moving the original must leave that slot
# alone: the sharer reads it for as long as it lives.
def test_move_string_sharing_its_embedded_bytes
assert_ractor(<<~'RUBY', timeout: 60)
[24, 100, 300].each do |len|
r = Ractor.new { Ractor.receive }
str = "x" * len
str.instance_variable_set(:@iv, []) # unshareable, so it is moved
str.freeze
dup = str.dup # reads str's bytes in place
r.send(str, move: true)
assert_equal "x" * len, dup, "corrupted for length #{len}"
r.value
end
RUBY
end
end