From ae2411c8f2f31927c16d85ceb7938a93d1e50201 Mon Sep 17 00:00:00 2001 From: Kevin Menard Date: Tue, 4 Aug 2026 17:34:39 -0400 Subject: [PATCH 01/12] ZJIT: Identify a recompiling side exit's instruction at compile time (GH-18108) `exit_recompile()` decides whether to invalidate a compiled unit by asking whether the interpreter has finished re-profiling the instruction that exited. It used to identify that instruction by reading `ec->cfp->iseq` and `ec->cfp->pc`. But, we already know those values at compile time, so there's no need to go through `ec`. Moreover, the indirection breaks for any exit that doesn't write its ISEQ and PC into the CFP, as is the case for virtual inline frames, where inlined callees have no physical control frame. There, `ec->cfp` is a perfectly valid CFP -- just not the one for the inlined ISEQ that exited -- so the check reads the profile counter of an unrelated instruction, which never reports as complete. The guard then asks for a recompile on every miss and never gets one. --- zjit/src/backend/lir.rs | 9 +++++++-- zjit/src/codegen.rs | 13 +++++++++++-- zjit/src/profile.rs | 6 ------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/zjit/src/backend/lir.rs b/zjit/src/backend/lir.rs index 2f3622b0838db7..c284bab6e9e096 100644 --- a/zjit/src/backend/lir.rs +++ b/zjit/src/backend/lir.rs @@ -617,6 +617,10 @@ pub struct SideExitRecompile { /// The compiled unit whose version must be invalidated to force a recompile. For inlined /// methods, this will be the outer function it was inlined into. pub compiled_iseq: Opnd, + /// The exiting frame's ISEQ, which owns the profile entry for `insn_idx`. For + /// an exit out of inlined code this is the inlined callee, not the compiled unit. + pub frame_iseq: Opnd, + /// The exiting frame's instruction index within `frame_iseq`. pub insn_idx: u32, } @@ -2898,8 +2902,9 @@ impl Assembler use crate::codegen::exit_recompile; asm_comment!(asm, "profile and maybe recompile"); asm_ccall!(asm, exit_recompile, - EC, - recompile.compiled_iseq + recompile.compiled_iseq, + recompile.frame_iseq, + recompile.insn_idx.into() ); } } diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index 0c84887a7b4644..61cfe75e41c279 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -3557,6 +3557,7 @@ fn side_exit_with_recompile(jit: &JITState, function: &Function, state: &FrameSt let mut exit = build_side_exit(jit, function, state); exit.recompile = recompile.map(|_| SideExitRecompile { compiled_iseq: Opnd::Value(VALUE::from(jit.iseq())), + frame_iseq: Opnd::Value(VALUE::from(state.iseq)), insn_idx: state.insn_idx() as u32, }); Target::SideExit(Box::new(SideExitTarget { exit, reason })) @@ -3623,7 +3624,14 @@ c_callable! { /// of inlined code, the inliner folds the callee's body into the outer ISEQ, so /// the outer ISEQ's version holds the failing guard and must be invalidated to /// force a recompile. For non-inlined code, it is the same as the frame ISEQ. - pub(crate) fn exit_recompile(ec: EcPtr, compiled_iseq_raw: VALUE) { + /// + /// `frame_iseq_raw` and `insn_idx` identify the instruction this exit came from, + /// whose re-profiling gates the recompile. Both are baked in at compile time, + /// where the exit already knows them, rather than read back out of the control + /// frame: the control frame describes the exiting frame only because the exit + /// wrote its ISEQ and PC there moments earlier, and an exit path that does not + /// write them would silently gate the recompile on an unrelated instruction. + pub(crate) fn exit_recompile(compiled_iseq_raw: VALUE, frame_iseq_raw: VALUE, insn_idx: u32) { // Fast check before taking the VM lock: skip if the compiled unit is already // invalidated or at the version limit. This avoids expensive lock acquisition // on every shape guard exit after the recompile has already been triggered. @@ -3643,7 +3651,8 @@ c_callable! { let compiled_iseq: IseqPtr = compiled_iseq_raw.as_iseq(); let should_recompile = with_time_stat(Counter::profile_time_ns, || { - crate::profile::profile_recompile_insn(ec) + get_or_create_iseq_payload(frame_iseq_raw.as_iseq()) + .profile.done_profiling_at(insn_idx as YarvInsnIdx) }); // Once we have enough profiles, invalidate the compiled unit so it diff --git a/zjit/src/profile.rs b/zjit/src/profile.rs index 3e8a2e1e8873b0..096b0871d26457 100644 --- a/zjit/src/profile.rs +++ b/zjit/src/profile.rs @@ -123,12 +123,6 @@ fn profile_insn(bare_opcode: ruby_vminsn_type, ec: EcPtr) { } } -/// Return whether the interpreter finished profiling the current instruction. -pub fn profile_recompile_insn(ec: EcPtr) -> bool { - let profiler = &Profiler::new(ec); - get_or_create_iseq_payload(profiler.iseq).profile.done_profiling_at(profiler.insn_idx) -} - /// Reset existing profile counters and install profiling instructions throughout an ISEQ. /// Newly reached instructions initialize their counters from the same option. pub(crate) fn reset_profiles_remaining(iseq: IseqPtr) { From f4e50b99937e2e3b758f31e9de40e40973b4f61c Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 4 Aug 2026 21:39:54 +0000 Subject: [PATCH 02/12] 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) --- internal/string.h | 1 + ractor.c | 10 +++++++++- string.c | 8 ++++++++ test/ruby/test_ractor.rb | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/string.h b/internal/string.h index 6dd6c2e4dd231e..a8893a42f1e10a 100644 --- a/internal/string.h +++ b/internal/string.h @@ -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); diff --git a/ractor.c b/ractor.c index fbf39478ffb771..4e741f000df0b6 100644 --- a/ractor.c +++ b/ractor.c @@ -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 diff --git a/string.c b/string.c index ce001d35aaec5d..9488baeda468c4 100644 --- a/string.c +++ b/string.c @@ -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) { diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 33387a156639a9..20e86317e90791 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -491,4 +491,22 @@ def test_move_matchdata_kept_in_backref 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 From 7fb28619dd52cabcb172949f95b34725b3e5b29a Mon Sep 17 00:00:00 2001 From: Max Bernstein Date: Sat, 1 Aug 2026 01:48:51 -0400 Subject: [PATCH 03/12] ZJIT: Add test for optimizing extended objects in load-store We should be able to see through the fields_obj but right now setivar/getivar disagree on what the return type is (RubyValue/BasicObject) so it doesn't get optimized. We will fix this in the next commit. --- zjit/src/hir/opt_tests.rs | 66 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index 641e01d05a328b..13245e8bd8ac9f 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -20754,4 +20754,70 @@ mod hir_opt_tests { Return v59 "); } + + #[test] + fn test_elide_load_store_extended() { + eval(r#" + class C + def initialize + @foo = 1 + 100.times { |i| instance_variable_set(:"@v#{i}", i) } + @hclk = 1 + @hclk_target = 2 + end + def foo = 4 + def wait_one_clock + @hclk += 1 + foo if @hclk_target <= @hclk + end + end + O = C.new + O.wait_one_clock + "#); + assert_snapshot!(hir_string_proc("C.instance_method(:wait_one_clock)"), @" + fn wait_one_clock@:11: + bb1(): + EntryPoint interpreter + v1:BasicObject = LoadSelf + Jump bb3(v1) + bb2(): + EntryPoint JIT(0) + v4:BasicObject = LoadArg :self@0 + Jump bb3(v4) + bb3(v6:BasicObject): + PatchPoint SingleRactorMode + v11:HeapBasicObject = GuardType v6, HeapBasicObject + v12:CShape = LoadField v11, :shape_id@0x1000 + v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile + v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v15:BasicObject = LoadField v14, :@hclk@0x1003 + v17:Fixnum[1] = Const Value(1) + PatchPoint MethodRedefined(Integer@0x1008, +@0x1010, cme:0x1018) + v71:Fixnum = GuardType v15, Fixnum recompile + v72:Fixnum = FixnumAdd v71, v17 + v26:BasicObject = LoadField v11, :as_heap@0x1002 + StoreField v26, :@hclk@0x1003, v72 + WriteBarrier v26, v72 + PatchPoint SingleRactorMode + v37:BasicObject = LoadField v14, :@hclk_target@0x1040 + v44:BasicObject = LoadField v14, :@hclk@0x1003 + PatchPoint MethodRedefined(Integer@0x1008, <=@0x1041, cme:0x1048) + v75:Fixnum = GuardType v37, Fixnum recompile + v76:Fixnum = GuardType v44, Fixnum + v77:BoolExact = FixnumLe v75, v76 + v49:CBool = Test v77 + CondBranch v49, bb5(), bb4(v11) + bb5(): + PatchPoint NoSingletonClass(C@0x1070) + PatchPoint MethodRedefined(C@0x1070, foo@0x1078, cme:0x1080) + v80:ObjectSubclass[class_exact:C] = GuardType v11, ObjectSubclass[class_exact:C] recompile + v81:Fixnum[4] = Const Value(4) + CheckInterrupts + Return v81 + bb4(v60:HeapBasicObject): + v63:NilClass = Const Value(nil) + CheckInterrupts + Return v63 + "); + } } From 88cc7a28952e91ed0301f8bc829ac6d6b386c480 Mon Sep 17 00:00:00 2001 From: Max Bernstein Date: Sat, 1 Aug 2026 01:34:10 -0400 Subject: [PATCH 04/12] ZJIT: Type fields_obj as RubyValue It's an imemo, so not a BasicObject. This way, also, the return types in setivar and getivar align and therefore we can optimize this better in load-store elimination. --- zjit/src/hir.rs | 6 +++--- zjit/src/hir/opt_tests.rs | 11 ++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index c3d7914670be7a..5abb0c002d9e3f 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -5640,7 +5640,7 @@ impl Function { (self_val, true) }, ShapeLayout::Extended => { - let fields = self.load_field(block, self_val, FieldName::as_heap, ROBJECT_OFFSET_AS_HEAP_FIELDS, types::BasicObject); + let fields = self.load_field(block, self_val, FieldName::as_heap, ROBJECT_OFFSET_AS_HEAP_FIELDS, types::RubyValue); (fields, false) }, ShapeLayout::Other | ShapeLayout::RClass => { @@ -6922,8 +6922,8 @@ impl Function { | Insn::NewRange { low: left, high: right, .. } | Insn::CheckMatch { target: left, pattern: right, .. } | Insn::WriteBarrier { recv: left, val: right } => { - self.assert_subtype(insn_id, left, types::BasicObject)?; - self.assert_subtype(insn_id, right, types::BasicObject) + self.assert_subtype(insn_id, left, types::RubyValue)?; + self.assert_subtype(insn_id, right, types::RubyValue) } Insn::GetConstant { klass, allow_nil, .. } => { self.assert_subtype(insn_id, klass, types::BasicObject)?; diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index 13245e8bd8ac9f..72e55fd4616916 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -6929,7 +6929,7 @@ mod hir_opt_tests { v17:HeapBasicObject = GuardType v9, HeapBasicObject v18:CShape = LoadField v17, :shape_id@0x1001 v19:CShape[0x1002] = GuardBitEquals v18, CShape(0x1002) recompile - v20:BasicObject = LoadField v17, :as_heap@0x1003 + v20:RubyValue = LoadField v17, :as_heap@0x1003 StoreField v20, :@v0@0x1003, v10 WriteBarrier v20, v10 CheckInterrupts @@ -20795,16 +20795,13 @@ mod hir_opt_tests { PatchPoint MethodRedefined(Integer@0x1008, +@0x1010, cme:0x1018) v71:Fixnum = GuardType v15, Fixnum recompile v72:Fixnum = FixnumAdd v71, v17 - v26:BasicObject = LoadField v11, :as_heap@0x1002 - StoreField v26, :@hclk@0x1003, v72 - WriteBarrier v26, v72 + StoreField v14, :@hclk@0x1003, v72 + WriteBarrier v14, v72 PatchPoint SingleRactorMode v37:BasicObject = LoadField v14, :@hclk_target@0x1040 - v44:BasicObject = LoadField v14, :@hclk@0x1003 PatchPoint MethodRedefined(Integer@0x1008, <=@0x1041, cme:0x1048) v75:Fixnum = GuardType v37, Fixnum recompile - v76:Fixnum = GuardType v44, Fixnum - v77:BoolExact = FixnumLe v75, v76 + v77:BoolExact = FixnumLe v75, v72 v49:CBool = Test v77 CondBranch v49, bb5(), bb4(v11) bb5(): From ce429a7d8dc75847607eb4e1999b30b5a49d3191 Mon Sep 17 00:00:00 2001 From: Max Bernstein Date: Mon, 3 Aug 2026 11:01:09 -0700 Subject: [PATCH 05/12] ZJIT: Add IMemo subtype of RubyValue Use this for extended fields. --- zjit/src/hir.rs | 4 +-- zjit/src/hir/opt_tests.rs | 20 +++++++------- zjit/src/hir_type/gen_hir_type.rb | 2 ++ zjit/src/hir_type/hir_type.inc.rs | 45 ++++++++++++++++--------------- 4 files changed, 38 insertions(+), 33 deletions(-) diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index 5abb0c002d9e3f..245f12aeadf401 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -5521,7 +5521,7 @@ impl Function { TDATA_OFFSET_FIELDS_OBJ }; - let fields_obj = self.load_field(block, self_val, FieldName::fields_obj, offset, types::RubyValue); + let fields_obj = self.load_field(block, self_val, FieldName::fields_obj, offset, types::IMemo); // All fields objects are embedded self.load_ivar_embedded(block, fields_obj, id, ivar_index) }, @@ -5640,7 +5640,7 @@ impl Function { (self_val, true) }, ShapeLayout::Extended => { - let fields = self.load_field(block, self_val, FieldName::as_heap, ROBJECT_OFFSET_AS_HEAP_FIELDS, types::RubyValue); + let fields = self.load_field(block, self_val, FieldName::as_heap, ROBJECT_OFFSET_AS_HEAP_FIELDS, types::IMemo); (fields, false) }, ShapeLayout::Other | ShapeLayout::RClass => { diff --git a/zjit/src/hir/opt_tests.rs b/zjit/src/hir/opt_tests.rs index 72e55fd4616916..580df9cd488bc2 100644 --- a/zjit/src/hir/opt_tests.rs +++ b/zjit/src/hir/opt_tests.rs @@ -6929,7 +6929,7 @@ mod hir_opt_tests { v17:HeapBasicObject = GuardType v9, HeapBasicObject v18:CShape = LoadField v17, :shape_id@0x1001 v19:CShape[0x1002] = GuardBitEquals v18, CShape(0x1002) recompile - v20:RubyValue = LoadField v17, :as_heap@0x1003 + v20:IMemo = LoadField v17, :as_heap@0x1003 StoreField v20, :@v0@0x1003, v10 WriteBarrier v20, v10 CheckInterrupts @@ -9507,7 +9507,7 @@ mod hir_opt_tests { v11:HeapBasicObject = GuardType v6, HeapBasicObject v12:CShape = LoadField v11, :shape_id@0x1000 v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile - v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v14:IMemo = LoadField v11, :fields_obj@0x1002 v15:BasicObject = LoadField v14, :@foo@0x1003 CheckInterrupts Return v15 @@ -9577,7 +9577,7 @@ mod hir_opt_tests { v11:HeapBasicObject = GuardType v6, HeapBasicObject v12:CShape = LoadField v11, :shape_id@0x1000 v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile - v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v14:IMemo = LoadField v11, :fields_obj@0x1002 v15:BasicObject = LoadField v14, :@foo@0x1003 CheckInterrupts Return v15 @@ -9640,7 +9640,7 @@ mod hir_opt_tests { v11:HeapBasicObject = GuardType v6, HeapBasicObject v12:CShape = LoadField v11, :shape_id@0x1000 v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile - v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v14:IMemo = LoadField v11, :fields_obj@0x1002 v15:BasicObject = LoadField v14, :@a@0x1002 CheckInterrupts Return v15 @@ -9675,7 +9675,7 @@ mod hir_opt_tests { v11:HeapBasicObject = GuardType v6, HeapBasicObject v12:CShape = LoadField v11, :shape_id@0x1000 v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile - v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v14:IMemo = LoadField v11, :fields_obj@0x1002 v15:BasicObject = LoadField v14, :@a@0x1002 CheckInterrupts Return v15 @@ -9817,7 +9817,7 @@ mod hir_opt_tests { Jump bb4(v17) bb6(): v19:CShape[0x1003] = GuardBitEquals v12, CShape(0x1003) recompile - v21:RubyValue = LoadField v11, :fields_obj@0x1004 + v21:IMemo = LoadField v11, :fields_obj@0x1004 v22:BasicObject = LoadField v21, :@foo@0x1004 Jump bb4(v22) bb4(v13:BasicObject): @@ -9880,7 +9880,7 @@ mod hir_opt_tests { v15:CBool = IsBitEqual v12, v14 CondBranch v15, bb5(), bb6() bb5(): - v17:RubyValue = LoadField v11, :fields_obj@0x1002 + v17:IMemo = LoadField v11, :fields_obj@0x1002 v18:BasicObject = LoadField v17, :@foo@0x1002 Jump bb4(v18) bb6(): @@ -9995,12 +9995,12 @@ mod hir_opt_tests { v15:CBool = IsBitEqual v12, v14 CondBranch v15, bb5(), bb6() bb5(): - v17:RubyValue = LoadField v11, :fields_obj@0x1002 + v17:IMemo = LoadField v11, :fields_obj@0x1002 v18:BasicObject = LoadField v17, :@a@0x1002 Jump bb4(v18) bb6(): v20:CShape[0x1003] = GuardBitEquals v12, CShape(0x1003) recompile - v22:RubyValue = LoadField v11, :fields_obj@0x1004 + v22:IMemo = LoadField v11, :fields_obj@0x1004 v23:BasicObject = LoadField v22, :@a@0x1002 Jump bb4(v23) bb4(v13:BasicObject): @@ -20789,7 +20789,7 @@ mod hir_opt_tests { v11:HeapBasicObject = GuardType v6, HeapBasicObject v12:CShape = LoadField v11, :shape_id@0x1000 v13:CShape[0x1001] = GuardBitEquals v12, CShape(0x1001) recompile - v14:RubyValue = LoadField v11, :fields_obj@0x1002 + v14:IMemo = LoadField v11, :fields_obj@0x1002 v15:BasicObject = LoadField v14, :@hclk@0x1003 v17:Fixnum[1] = Const Value(1) PatchPoint MethodRedefined(Integer@0x1008, +@0x1010, cme:0x1018) diff --git a/zjit/src/hir_type/gen_hir_type.rb b/zjit/src/hir_type/gen_hir_type.rb index 8db620123b72d7..a87f3b821b749c 100644 --- a/zjit/src/hir_type/gen_hir_type.rb +++ b/zjit/src/hir_type/gen_hir_type.rb @@ -47,7 +47,9 @@ def to_graphviz type, f any = Type.new "Any" # Build the Ruby object universe. value = any.subtype "RubyValue" +imemo = value.subtype "IMemo" undef_ = value.subtype "Undef" +# TODO(max): Figure out if CME should be a subtype of IMemo value.subtype "CallableMethodEntry" # rb_callable_method_entry_t* basic_object = value.subtype "BasicObject" basic_object_exact = basic_object.subtype "BasicObjectExact" diff --git a/zjit/src/hir_type/hir_type.inc.rs b/zjit/src/hir_type/hir_type.inc.rs index ca3ddf6a9ec00e..aa9fbf987f5f00 100644 --- a/zjit/src/hir_type/hir_type.inc.rs +++ b/zjit/src/hir_type/hir_type.inc.rs @@ -45,40 +45,41 @@ mod bits { pub const HeapBasicObject: u64 = BasicObject & !Immediate; pub const HeapFloat: u64 = 1u64 << 28; pub const HeapObject: u64 = Object & !Immediate; + pub const IMemo: u64 = 1u64 << 29; pub const Immediate: u64 = FalseClass | Fixnum | Flonum | NilClass | StaticSymbol | TrueClass | Undef; pub const Integer: u64 = Bignum | Fixnum; pub const Module: u64 = Class | ModuleExact | ModuleSubclass; - pub const ModuleExact: u64 = 1u64 << 29; - pub const ModuleSubclass: u64 = 1u64 << 30; - pub const NilClass: u64 = 1u64 << 31; + pub const ModuleExact: u64 = 1u64 << 30; + pub const ModuleSubclass: u64 = 1u64 << 31; + pub const NilClass: u64 = 1u64 << 32; pub const NotNil: u64 = BasicObject & !NilClass; pub const NotString: u64 = BasicObject & !String; pub const Numeric: u64 = Float | Integer | NumericExact | NumericSubclass; - pub const NumericExact: u64 = 1u64 << 32; - pub const NumericSubclass: u64 = 1u64 << 33; + pub const NumericExact: u64 = 1u64 << 33; + pub const NumericSubclass: u64 = 1u64 << 34; pub const Object: u64 = Array | FalseClass | Hash | Module | NilClass | Numeric | ObjectExact | ObjectSubclass | Range | Regexp | Set | String | Symbol | TrueClass; - pub const ObjectExact: u64 = 1u64 << 34; - pub const ObjectSubclass: u64 = 1u64 << 35; + pub const ObjectExact: u64 = 1u64 << 35; + pub const ObjectSubclass: u64 = 1u64 << 36; pub const Range: u64 = RangeExact | RangeSubclass; - pub const RangeExact: u64 = 1u64 << 36; - pub const RangeSubclass: u64 = 1u64 << 37; + pub const RangeExact: u64 = 1u64 << 37; + pub const RangeSubclass: u64 = 1u64 << 38; pub const Regexp: u64 = RegexpExact | RegexpSubclass; - pub const RegexpExact: u64 = 1u64 << 38; - pub const RegexpSubclass: u64 = 1u64 << 39; - pub const RubyValue: u64 = BasicObject | CallableMethodEntry | Undef; + pub const RegexpExact: u64 = 1u64 << 39; + pub const RegexpSubclass: u64 = 1u64 << 40; + pub const RubyValue: u64 = BasicObject | CallableMethodEntry | IMemo | Undef; pub const Set: u64 = SetExact | SetSubclass; - pub const SetExact: u64 = 1u64 << 40; - pub const SetSubclass: u64 = 1u64 << 41; - pub const StaticSymbol: u64 = 1u64 << 42; + pub const SetExact: u64 = 1u64 << 41; + pub const SetSubclass: u64 = 1u64 << 42; + pub const StaticSymbol: u64 = 1u64 << 43; pub const String: u64 = StringExact | StringSubclass; - pub const StringExact: u64 = 1u64 << 43; - pub const StringSubclass: u64 = 1u64 << 44; + pub const StringExact: u64 = 1u64 << 44; + pub const StringSubclass: u64 = 1u64 << 45; pub const Subclass: u64 = ArraySubclass | BasicObjectSubclass | ClassSubclass | HashSubclass | ModuleSubclass | NumericSubclass | ObjectSubclass | RangeSubclass | RegexpSubclass | SetSubclass | StringSubclass; pub const Symbol: u64 = DynamicSymbol | StaticSymbol; - pub const TrueClass: u64 = 1u64 << 45; + pub const TrueClass: u64 = 1u64 << 46; pub const Truthy: u64 = BasicObject & !Falsy; - pub const Undef: u64 = 1u64 << 46; - pub const AllBitPatterns: [(&str, u64); 78] = [ + pub const Undef: u64 = 1u64 << 47; + pub const AllBitPatterns: [(&str, u64); 79] = [ ("Any", Any), ("RubyValue", RubyValue), ("Immediate", Immediate), @@ -118,6 +119,7 @@ mod bits { ("Module", Module), ("ModuleSubclass", ModuleSubclass), ("ModuleExact", ModuleExact), + ("IMemo", IMemo), ("Float", Float), ("HeapFloat", HeapFloat), ("Hash", Hash), @@ -158,7 +160,7 @@ mod bits { ("ArrayExact", ArrayExact), ("Empty", Empty), ]; - pub const NumTypeBits: u64 = 47; + pub const NumTypeBits: u64 = 48; } pub mod types { use super::*; @@ -207,6 +209,7 @@ pub mod types { pub const HeapBasicObject: Type = Type::from_bits(bits::HeapBasicObject); pub const HeapFloat: Type = Type::from_bits(bits::HeapFloat); pub const HeapObject: Type = Type::from_bits(bits::HeapObject); + pub const IMemo: Type = Type::from_bits(bits::IMemo); pub const Immediate: Type = Type::from_bits(bits::Immediate); pub const Integer: Type = Type::from_bits(bits::Integer); pub const Module: Type = Type::from_bits(bits::Module); From 3a3f32c699a269b09b2d6536c7ef819ae9f0c680 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Mon, 3 Aug 2026 19:18:52 +0000 Subject: [PATCH 06/12] ZJIT: clear cfp->block_code in the materialize_exit trampoline gen_materialize_exit_trampoline clears cfp->jit_return and then calls rb_zjit_materialize_frames. CFP_ZJIT_FRAME_P() is exactly cfp->jit_return != NULL, so by then the exiting frame no longer looks like a JIT frame and zjit_materialize_frames() skips its whole per-frame body, including the branch that clears block_code: if (jit_frame->materialize_block_code) { cfp->block_code = NULL; } Nothing else clears it. compile_exit_save_state() does not write block_code, and its comment in zjit/src/backend/lir.rs already states that this trampoline clears both fields, which it never did. The exiting frame therefore keeps the block_code ZJIT wrote for it, and rb_execution_context_mark() marks cfp->block_code; once that iseq is collected, marking it is "[BUG] try to mark T_NONE". An instrumented build counts how often the exiting frame reaches zjit_materialize_frames() with a non-NULL block_code: 5 times in the first 162 trampoline calls of a single btest process on master, 0 with this change. Clearing jit_return after materializing instead would be wrong: skipping the exiting frame is deliberate, since compile_exit_save_state() has already written its state and the stack-map restore would overwrite it. Co-authored-by: Claude Opus 5 (1M context) --- zjit/src/codegen.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index 61cfe75e41c279..ebe221294470e4 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -3977,6 +3977,10 @@ pub fn gen_materialize_exit_trampoline(cb: &mut CodeBlock, exit_trampoline: Code asm_comment!(asm, "clear JITFrame materialized by exit code"); asm.store(Opnd::mem(64, CFP, RUBY_OFFSET_CFP_JIT_RETURN), 0.into()); + // zjit_materialize_frames() identifies a JIT frame by a non-NULL jit_return, so + // it skips this frame and never runs its materialize_block_code branch. Clear + // block_code here, as compile_exit_save_state() already documents we do. + asm.store(Opnd::mem(64, CFP, RUBY_OFFSET_CFP_BLOCK_CODE), 0.into()); asm_comment!(asm, "materialize ZJIT frames"); asm_ccall!(asm, rb_zjit_materialize_frames, EC, CFP); From ee622200737795c4089fc89ff4af2e50391842bc Mon Sep 17 00:00:00 2001 From: XrXr Date: Tue, 4 Aug 2026 17:38:26 -0400 Subject: [PATCH 07/12] ZJIT: Fix cfp->block_code comment --- zjit/src/codegen.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zjit/src/codegen.rs b/zjit/src/codegen.rs index ebe221294470e4..27428f9d63a9aa 100644 --- a/zjit/src/codegen.rs +++ b/zjit/src/codegen.rs @@ -3977,9 +3977,9 @@ pub fn gen_materialize_exit_trampoline(cb: &mut CodeBlock, exit_trampoline: Code asm_comment!(asm, "clear JITFrame materialized by exit code"); asm.store(Opnd::mem(64, CFP, RUBY_OFFSET_CFP_JIT_RETURN), 0.into()); - // zjit_materialize_frames() identifies a JIT frame by a non-NULL jit_return, so - // it skips this frame and never runs its materialize_block_code branch. Clear - // block_code here, as compile_exit_save_state() already documents we do. + // Clear cfp->block_code since it may have been left uninitialized by JITFrame mechanisms. + // Zero is the right value because we're dealing with the top most frame. + // Non-zero values are only set before pushing a frame. asm.store(Opnd::mem(64, CFP, RUBY_OFFSET_CFP_BLOCK_CODE), 0.into()); asm_comment!(asm, "materialize ZJIT frames"); From b938d066d848739d72c1bac0b1a781ea1bcbcfdf Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Fri, 31 Jul 2026 15:11:16 -0400 Subject: [PATCH 08/12] [ruby/erb] Fix Ractor compatibility regression, add tests The recent change to use `BasicObject.instance_method(:equal?)` broke the ability to share frozen ERB templates across Ractors because `UnboundMethod` isn't shareable. Freezing the `UnboundMethod` _may_ fix the issue (dependong on Ruby version), but replacing the constant with an inline call to the `singleton_class` is simpler (and still avoids calling `equal?` on `@init`). There have previously been many contributions to make ERB Ractor safe, but no tests added to ensure it continues to be Ractor safe, so this commit also adds some regression tests. https://github.com/ruby/erb/commit/75f1ea059e --- lib/erb.rb | 5 +---- test/erb/test_erb.rb | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/erb.rb b/lib/erb.rb index 0c4653d291f28d..040cd183528b05 100644 --- a/lib/erb.rb +++ b/lib/erb.rb @@ -815,9 +815,6 @@ # [template processor]: https://en.wikipedia.org/wiki/Template_processor # class ERB - IDENTITY_METHOD = BasicObject.instance_method(:equal?) # :nodoc: - private_constant :IDENTITY_METHOD - # :markup: markdown # # :call-seq: @@ -1117,7 +1114,7 @@ def new_toplevel(vars = nil) private :new_toplevel def initialized_by_new? # :nodoc: - IDENTITY_METHOD.bind_call(@_init, self.class.singleton_class) + self.class.singleton_class.equal? @_init end private :initialized_by_new? diff --git a/test/erb/test_erb.rb b/test/erb/test_erb.rb index 1de892544e5b10..6da25146ff7966 100644 --- a/test/erb/test_erb.rb +++ b/test/erb/test_erb.rb @@ -740,3 +740,43 @@ def teardown ERB::Compiler::Scanner.instance_variable_set('@scanner_map', @save_map) end end + +class TestERBRactor < Test::Unit::TestCase + def test_compile_and_result_in_ractor + assert_ractor(<<~RUBY, require: 'erb') + r = Ractor.new do + ERB.new("Hello, <%= 'world' %>!").result(binding) + end + assert_equal("Hello, world!", r.value) + RUBY + end + + def test_trim_mode_in_ractor + assert_ractor(<<~RUBY, require: 'erb') + src = "<% [1, 2].each do |i| %>\\n<%= i %>\\n<% end %>\\n" + r = Ractor.new(src) { |s| ERB.new(s, trim_mode: '-').result(binding) } + assert_equal("\\n1\\n\\n2\\n\\n", r.value) + + r = Ractor.new(src) { |s| ERB.new(s, trim_mode: '<>').result(binding) } + assert_equal("12", r.value) + RUBY + end + + def test_frozen_erb_instance_reused_across_ractors + assert_ractor(<<~RUBY, require: 'erb') + erb = ERB.new("<%= 1 + 1 %>") + erb.freeze + rs = 2.times.map { Ractor.new(erb) { |e| e.result(binding) } } + assert_equal(["2", "2"], rs.map(&:value)) + RUBY + end + + def test_util_html_escape_in_ractor + assert_ractor(<<~RUBY, require: 'erb') + r = Ractor.new do + ERB::Util.html_escape("