Ractor: wipe the body of an object hollowed out by move

Ractor#send(move: true) turns the source object into a frozen
Ractor::MovedObject in place: it rewrites the header and gives the slot a
field-less shape, but leaves the old body untouched.  Ruby-level access is
blocked by method_missing, yet C code that still holds the object from before
the move keeps reading it with its old type.

The visible case is $~ holding a MatchData that is then moved away:

    r = Ractor.new { Ractor.receive }
    "abc123xyz".match(/([a-z]+)(\\d+)/)
    r.send($~, move: true)
    "qqq777".match(/([a-z]+)(\\d+)/).frozen?   #=> true

match_alloc_or_reuse() decides on MATCH_BUSY and RMATCH(existing)->capa, and
the husk satisfies both: rewriting the header cleared MATCH_BUSY, and capa
still reads the old capacity.  RMatch fields are then written into a frozen
T_OBJECT whose shape says it has no fields, and once the GC walks it as a
ROBJECT the heap is corrupted: [BUG] try to mark T_NONE, out-of-heap,
use-after-poison in RSTRING_LEN, or silently wrong captures.

That the reuse is the cause is easy to confirm: if the following match needs
more registers than the husk's stale capa, no reuse happens and the same
program is clean.

Check the type in match_alloc_or_reuse() before trusting the body, and zero
the body when hollowing an object out, so that any other stale typed read
sees an empty object instead of the old internals (an Enumerator whose
Array#each is in progress, for instance, reads a length of 0 and stops
instead of walking off the end).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Koichi SasadaandClaude Opus 5 committed 2026-08-05 01:48:44 +09:00
1 parent 036159508e
commit 556296cbe0
3 files changed
+27
Notes: git 2026-08-04 16:49:20 +00:00

No files matched your search

+7
View File
@@ -2125,10 +2125,17 @@ move_leave(VALUE obj, struct obj_traverse_replace_data *data)
shape_id_t shape_id = (RBASIC_SHAPE_ID(obj) & SHAPE_ID_CAPACITY_MASK) | ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_ROBJECT | SHAPE_ID_FL_FROZEN;
// Avoid mutations using bind_call, etc.
size_t slot_size = rb_gc_obj_slot_size(obj);
MEMZERO((char *)obj, char, sizeof(struct RBasic));
RBASIC(obj)->flags = flags;
RBASIC_SET_CLASS_RAW(obj, rb_cRactorMovedObject);
// Wipe the old body too. The husk has no fields, so nothing reads it as ivars,
// 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));
// 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
// invariant is violated.
+5
View File
@@ -1582,7 +1582,12 @@ rb_match_count(VALUE match)
static VALUE
match_alloc_or_reuse(VALUE existing, int num_regs)
{
/* $~ can hold a Ractor::MovedObject: Ractor#send(move: true) hollows the
* MatchData out in place and the husk keeps the old RMatch body, so its capa
* still reads as reusable. Reusing it would write RMatch fields into a frozen
* T_OBJECT, so check the type before trusting the body. */
if (!NIL_P(existing) &&
RB_TYPE_P(existing, T_MATCH) &&
!FL_TEST(existing, MATCH_BUSY) &&
RMATCH(existing)->capa >= num_regs * 2) {
return existing;
+15
View File
@@ -476,4 +476,19 @@ class TestRactor < Test::Unit::TestCase
end
refute Ractor.shareable?(obj), "despite raising, object became shareable"
end
# $~ can hold the MatchData that a move hollows out in place. The husk keeps
# the old RMatch body, so a later match must allocate instead of reusing it
# (a reused husk stays frozen and keeps its Ractor::MovedObject shape).
def test_move_matchdata_kept_in_backref
assert_ractor(<<~'RUBY', timeout: 60)
r = Ractor.new { Ractor.receive }
"abc123xyz".match(/([a-z]+)(\d+)/) # $~ holds the MatchData
r.send($~, move: true) # husked in place; $~ still points at it
m = "qqq777".match(/([a-z]+)(\d+)/)
assert_instance_of MatchData, m
refute_predicate m, :frozen?
assert_equal ["qqq777", "qqq", "777"], [m[0], m[1], m[2]]
r.value
RUBY
end
end