From 83185d33c423f044b5e4c6e3e710dafa54471174 Mon Sep 17 00:00:00 2001 From: Benjamin Quorning Date: Tue, 8 Sep 2026 17:15:06 +0200 Subject: [PATCH 01/36] [ruby/json] Update documentation to match json 3.0 API json 3.0 changed `JSON.parse` to take keyword arguments instead of a positional opts Hash, so some of the documented usage would now raise `ArgumentError`. I have updated all such examples to use kwargs instead. Also align documented output with what is actually produced with the current parser/generator on Ruby 4.0, plus a number of other minor changes. https://github.com/ruby/json/commit/8bdd5179df --- ext/json/lib/json.rb | 74 ++++++++++++++++++------------------- ext/json/lib/json/common.rb | 8 ++-- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/ext/json/lib/json.rb b/ext/json/lib/json.rb index 1e7573df97031b..f5f5ade89e1666 100644 --- a/ext/json/lib/json.rb +++ b/ext/json/lib/json.rb @@ -44,13 +44,13 @@ # # You can parse a \String containing \JSON data using # either of two methods: -# - JSON.parse(source, opts) -# - JSON.parse!(source, opts) +# - JSON.parse(source, **opts) +# - JSON.parse!(source, **opts) # # where # - +source+ is a Ruby object. -# - +opts+ is a \Hash object containing options -# that control both input allowed and output formatting. +# - +opts+ are keyword arguments that control both input +# allowed and output formatting. # # The difference between the two methods # is that JSON.parse! omits some checks @@ -102,7 +102,7 @@ # ruby # => 1.0 # ruby.class # => Float # ruby = JSON.parse('2.0e2') -# ruby # => 200 +# ruby # => 200.0 # ruby.class # => Float # Boolean: # ruby = JSON.parse('true') @@ -131,10 +131,10 @@ # ruby # => [0, [1, [2, [3]]]] # Too deep: # # Raises JSON::NestingError (nesting of 2 is too deep): -# JSON.parse(source, {max_nesting: 1}) +# JSON.parse(source, max_nesting: 1) # Bad value: -# # Raises TypeError (wrong argument type Symbol (expected Fixnum)): -# JSON.parse(source, {max_nesting: :foo}) +# # Raises TypeError (no implicit conversion of Symbol into Integer): +# JSON.parse(source, max_nesting: :foo) # # --- # @@ -142,11 +142,11 @@ # should be ignored or cause an error to be raised: # # When set to +false+, the default: -# JSON.parse('{"a": 1, "a":2}') => duplicate key at line 1 column 1 (JSON::ParserError) +# JSON.parse('{"a": 1, "a": 2}') # duplicate key "a" at line 1 column 1 (JSON::ParserError) # # When set to +true+: # # The last value is used. -# JSON.parse('{"a": 1, "a":2}', allow_duplicate_key: true) => {"a" => 2} +# JSON.parse('{"a": 1, "a": 2}', allow_duplicate_key: true) # => {"a" => 2} # # --- # @@ -155,15 +155,15 @@ # defaults to +false+. # # With the default, +false+: -# # Raises JSON::ParserError (225: unexpected token at '[NaN]'): +# # Raises JSON::ParserError (unexpected token 'NaN]' at line 1 column 2): # JSON.parse('[NaN]') -# # Raises JSON::ParserError (232: unexpected token at '[Infinity]'): +# # Raises JSON::ParserError (unexpected token 'Infinity]' at line 1 column 2): # JSON.parse('[Infinity]') -# # Raises JSON::ParserError (248: unexpected token at '[-Infinity]'): +# # Raises JSON::ParserError (invalid number: '-Infinity]' at line 1 column 2): # JSON.parse('[-Infinity]') # Allow: # source = '[NaN, Infinity, -Infinity]' -# ruby = JSON.parse(source, {allow_nan: true}) +# ruby = JSON.parse(source, allow_nan: true) # ruby # => [NaN, Infinity, -Infinity] # # --- @@ -185,10 +185,10 @@ # defaults to +false+. # # When set to +false+, the default: -# JSON.parse('/* comment */ {"a": 1, "a":2}') # unexpected character: '/' at line 1 column 1 (JSON::ParserError) +# JSON.parse('/* comment */ {"a": 1, "a": 2}') # unexpected token '/*' at line 1 column 1 (JSON::ParserError) # # When set to +true+, comments are ignored: -# JSON.parse('/* comment */ {"a": 1, "a":2} // more comment') # => {"a" => 2} +# JSON.parse('/* comment */ {"a": 1} // more comment', allow_comments: true) # => {"a" => 1} # # --- # @@ -197,7 +197,7 @@ # defaults to +false+. # # With the default, +false+: -# JSON.parse(%{"Hello\nWorld"}) # invalid ASCII control character in string (JSON::ParserError) +# JSON.parse(%{"Hello\nWorld"}) # invalid ASCII control character in string: \nWorld" at line 2 column 0 (JSON::ParserError) # # When enabled: # JSON.parse(%{"Hello\nWorld"}, allow_control_characters: true) # => "Hello\nWorld" @@ -209,7 +209,7 @@ # defaults to +false+. # # With the default, +false+: -# JSON.parse('"Hell\o"') # invalid escape character in string (JSON::ParserError) +# JSON.parse('"Hell\o"') # invalid escape character in string: '\o"' at line 1 column 6 (JSON::ParserError) # # When enabled: # JSON.parse('"Hell\o"', allow_invalid_escape: true) # => "Hello" @@ -228,8 +228,8 @@ # ruby = JSON.parse(source) # ruby # => {"a"=>"foo", "b"=>1.0, "c"=>true, "d"=>false, "e"=>nil} # Use Symbols: -# ruby = JSON.parse(source, {symbolize_names: true}) -# ruby # => {:a=>"foo", :b=>1.0, :c=>true, :d=>false, :e=>nil} +# ruby = JSON.parse(source, symbolize_names: true) +# ruby # => {a: "foo", b: 1.0, c: true, d: false, e: nil} # # --- # @@ -242,7 +242,7 @@ # ruby = JSON.parse(source) # ruby.class # => Hash # Use class \OpenStruct: -# ruby = JSON.parse(source, {object_class: OpenStruct}) +# ruby = JSON.parse(source, object_class: OpenStruct) # ruby # => # # # --- @@ -256,8 +256,8 @@ # ruby = JSON.parse(source) # ruby.class # => Array # Use class \Set: -# ruby = JSON.parse(source, {array_class: Set}) -# ruby # => # +# ruby = JSON.parse(source, array_class: Set) +# ruby # => Set["foo", 1.0, true, false, nil] # # === Generating \JSON # @@ -319,22 +319,22 @@ # a \String containing a \JSON string representation of the source: # JSON.generate(:foo) # => '"foo"' # JSON.generate(Complex(0, 0)) # => '"0+0i"' -# JSON.generate(Dir.new('.')) # => '"#"' +# JSON.generate(Dir.new('.')) # => '"#"' # # ==== Generating Options # # ====== Input Options # # Option +allow_nan+ (boolean) specifies whether -# +NaN+, +Infinity+, and -Infinity may be generated; +# +NaN+, +Infinity+, and +-Infinity+ may be generated; # defaults to +false+. # # With the default, +false+: -# # Raises JSON::GeneratorError (920: NaN not allowed in JSON): +# # Raises JSON::GeneratorError (NaN not allowed in JSON): # JSON.generate(JSON::NaN) -# # Raises JSON::GeneratorError (917: Infinity not allowed in JSON): +# # Raises JSON::GeneratorError (Infinity not allowed in JSON): # JSON.generate(JSON::Infinity) -# # Raises JSON::GeneratorError (917: -Infinity not allowed in JSON): +# # Raises JSON::GeneratorError (-Infinity not allowed in JSON): # JSON.generate(JSON::MinusInfinity) # # Allow: @@ -345,14 +345,14 @@ # # Option +allow_duplicate_key+ (boolean) specifies whether # hashes with duplicate keys should be allowed or produce an error. -# defaults to emit a deprecation warning. +# Defaults to +false+, which raises an error. # -# With the default, false: -# JSON.generate({ foo: 1, "foo" => 2 }) +# With the default, +false+: +# JSON.generate({foo: 1, "foo" => 2}) # # detected duplicate key "foo" in {foo: 1, "foo" => 2} (JSON::GeneratorError) # -# With true -# JSON.generate({ foo: 1, "foo" => 2 }, allow_duplicate_key: true) +# With +true+: +# JSON.generate({foo: 1, "foo" => 2}, allow_duplicate_key: true) # # => '{"foo":1,"foo":2}' # # --- @@ -365,13 +365,13 @@ # JSON.generate(obj) # => '[[[[[[0]]]]]]' # # Too deep: -# # Raises JSON::NestingError (nesting of 2 is too deep): +# # Raises JSON::NestingError (nesting of 2 is too deep. Did you try to serialize objects with circular references?): # JSON.generate(obj, max_nesting: 2) # # With +false+: # obj = [] # obj[0] = obj -# # Raises SystemStackError: stack level too deep +# # Raises SystemStackError (stack level too deep): # JSON.generate(obj, max_nesting: false) # # Setting +max_nesting+ to +false+ or a very large number can lead to a stack overflow @@ -410,7 +410,7 @@ # inserted before the colon in each \JSON object's pair; # defaults to the empty \String, ''. # - Option +sort_keys+ (boolean or \Proc) controls whether and how the keys of a -# hash are sorted when generating the output; defaults to false. +# hash are sorted when generating the output; defaults to +false+. # When +true+, keys are sorted lexicographically. When a \Proc, it receives # the entire \Hash and must return a \Hash with its pairs in the desired # order, allowing for arbitrary sort orders. @@ -439,7 +439,7 @@ # "foo" : [ # "bar", # "baz" -# ], +# ], # "bat" : { # "bam" : 0, # "bad" : 1 diff --git a/ext/json/lib/json/common.rb b/ext/json/lib/json/common.rb index edcd93d4d442d2..ebec553a25464d 100644 --- a/ext/json/lib/json/common.rb +++ b/ext/json/lib/json/common.rb @@ -143,11 +143,11 @@ class JSONError < StandardError; end # This exception is raised if a parser error occurs. class ParserError < JSONError # Line number where the parser encountered an error. - # Is nil when raised by JSON::ResumableParser. + # Is +nil+ when raised by JSON::ResumableParser. attr_reader :line # Column number where the parser encountered an error. - # Is nil when raised by JSON::ResumableParser. + # Is +nil+ when raised by JSON::ResumableParser. attr_reader :column # Returns a best effort JSONPath string representing where in the document @@ -290,7 +290,7 @@ def to_json(state = nil, *) # --- # # Raises an exception if +source+ is not valid JSON: - # # Raises JSON::ParserError unexpected character: 'invalid' at line 1 column 1 : + # # Raises JSON::ParserError (unexpected character: 'invalid' at line 1 column 1): # JSON.parse('invalid') # def parse(source, on_load: nil, object_class: nil, array_class: nil, **options) @@ -372,7 +372,7 @@ def load_file!(filespec, **options) # # Raises an exception if +obj+ contains circular references: # a = []; b = []; a.push(b); b.push(a) - # # Raises JSON::NestingError (nesting of 100 is too deep): + # # Raises JSON::NestingError (nesting of 100 is too deep. Did you try to serialize objects with circular references?): # JSON.generate(a) # def generate(obj, opts = nil) From a4c740449bfe792dbb7179e83f82bef1f5d09949 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 8 Sep 2026 15:55:50 +0000 Subject: [PATCH 02/36] ractor.h: export rb_obj_set_shareable() RB_OBJ_SET_SHAREABLE() was unusable from extensions: the declaration was outside RBIMPL_SYMBOL_EXPORT_BEGIN/END, so the symbol was compiled with hidden visibility and did not appear in libruby.so. Also document it, including the cost that a shareable object is not collected by a local GC. Co-Authored-By: Claude Opus 5 (1M context) --- include/ruby/ractor.h | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/include/ruby/ractor.h b/include/ruby/ractor.h index 8cfca2162107c8..afed7627f2a1fa 100644 --- a/include/ruby/ractor.h +++ b/include/ruby/ractor.h @@ -221,6 +221,26 @@ VALUE rb_ractor_make_shareable(VALUE obj); */ VALUE rb_ractor_make_shareable_copy(VALUE obj); +/** + * Marks the passed object as shareable, without any check. This is for + * objects which the caller knows are safe to be shared among Ractors, for + * instance because they are frozen and only refer to shareable objects. + * + * @param[out] obj Arbitrary ruby object, except special constants. + * @return Passed `obj`. + * @post Multiple Ractors can share `obj`. + * @warning No check is done. Marking an unsafe object as shareable + * breaks the Ractor isolation. + * @warning Do not make objects shareable more than needed. A shareable + * object is out of the reach of a local GC, so it is not + * collected until a global GC runs. This is for the few objects + * which live as long as the process, typically internal + * constants, not for objects created per call. + * @note Do not call this directly; use #RB_OBJ_SET_SHAREABLE, or + * #RB_OBJ_SET_FROZEN_SHAREABLE to freeze at the same time. + */ +VALUE rb_obj_set_shareable(VALUE obj); + RBIMPL_SYMBOL_EXPORT_END() /** @@ -262,11 +282,21 @@ rb_ractor_shareable_p(VALUE obj) } // TODO: optimize on interpreter core + +/** + * Wrapper of rb_obj_set_shareable(). Use this macro, not the function. + */ #ifndef RB_OBJ_SET_SHAREABLE -VALUE rb_obj_set_shareable(VALUE obj); // ractor.c #define RB_OBJ_SET_SHAREABLE(obj) rb_obj_set_shareable(obj) #endif +/** + * Freezes and marks the object as shareable. The same warning and note as + * rb_obj_set_shareable() apply. + * + * @param[out] obj Arbitrary ruby object, except special constants. + * @return Passed `obj`. + */ static inline VALUE RB_OBJ_SET_FROZEN_SHAREABLE(VALUE obj) { From 2b94a886ded20224e0651342d4d727b9a56ba863 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 8 Sep 2026 17:32:25 +0100 Subject: [PATCH 03/36] [ruby/erb] Get rid of some useless global variables (https://github.com/ruby/erb/pull/138) https://github.com/ruby/erb/commit/dc4c7a0e36 --- ext/erb/escape/escape.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index 1794fc30ebdce7..79e23c7c7813ba 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -1,7 +1,7 @@ #include "ruby.h" #include "ruby/encoding.h" -static VALUE rb_cERB, rb_mEscape, rb_cCGI; +static VALUE rb_cCGI; static ID id_escapeHTML; #define HTML_ESCAPE_MAX_LEN 6 @@ -105,8 +105,8 @@ Init_escape(void) rb_ext_ractor_safe(true); #endif - rb_cERB = rb_define_class("ERB", rb_cObject); - rb_mEscape = rb_define_module_under(rb_cERB, "Escape"); + VALUE rb_cERB = rb_define_class("ERB", rb_cObject); + VALUE rb_mEscape = rb_define_module_under(rb_cERB, "Escape"); rb_define_module_function(rb_mEscape, "html_escape", erb_escape_html, 1); rb_cCGI = rb_define_class("CGI", rb_cObject); From 42b3ac177e77fe4bdb0bf7369ac131111e312ad5 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 8 Sep 2026 17:33:46 +0100 Subject: [PATCH 04/36] [ruby/erb] Create the escaped string in the right encoding directly (https://github.com/ruby/erb/pull/139) Changing a string encoding is more work than directly creating it with the right encoding, as Ruby has to check if the TERM_LEN matches etc. https://github.com/ruby/erb/commit/ae26cc80cf --- ext/erb/escape/escape.c | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index 79e23c7c7813ba..b3184ddc6b7c7f 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -19,12 +19,6 @@ static const struct { #undef HTML_ESCAPE }; -static inline void -preserve_original_state(VALUE orig, VALUE dest) -{ - rb_enc_associate(dest, rb_enc_get(orig)); -} - static inline long escaped_length(VALUE str) { @@ -70,8 +64,7 @@ optimized_escape_html(VALUE str) memcpy(dest, segment_start, segment_len); dest += segment_len; } - escaped = rb_str_new(buf, dest - buf); - preserve_original_state(str, escaped); + escaped = rb_enc_str_new(buf, dest - buf, rb_enc_get(str)); ALLOCV_END(vbuf); } return escaped; From 904d633544ac2fcd55c35b9acbbad4c464609ec1 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 8 Sep 2026 09:46:32 +0000 Subject: [PATCH 05/36] Build a child Ractor's wrappers in its objspace by naming it, not by moving it create_ractor_alloc_thread() pointed cr->objspace at the child's objspace while it allocated the child's Thread and root Fiber wrappers, so that they would be made of objects the child owns. That slot is read without the VM lock by threads that hold no GVL -- a Ractor's postmortem epilogue, which clears the TLS EC before freeing the dead thread, thread_sched_reclaim, and any thread that released the GVL inside rb_nogvl -- to decide which objspace to charge a free to. One of them could therefore be sent to the child's objspace; a stillborn child (IsolationError) is then disowned and freed by the orphan merge job, and the free landed in released memory (ASAN heap-use-after-free at the malloc accounting in ruby_xfree_sized, seen once in 209 CI runs). The window allocates exactly two objects, so hand the objspace down to them instead: rb_newobj_in_objspace() and the two TypedData entry points built on it carry it to thread_alloc() and fiber_alloc_in(). cr->objspace never moves, so no other thread can observe anything, and rb_gc_get_objspace() is unchanged. GC suppression around the wrappers now names the child's objspace too, which is the one the allocations go to; resolving it through the current Ractor would suppress the creator's instead and let a cycle collect the half-built child. The creation cover goes up before the first allocation rather than after the last one, and the allocation-failure path parks the objspace in the same VM-lock section, so a global GC can never find it neither covered nor a zombie. Co-Authored-By: Claude Opus 5 (1M context) --- cont.c | 14 ++- .../gvl/call_without_gvl/call_without_gvl.c | 26 +++++ gc.c | 94 +++++++++++++++---- internal/gc.h | 7 ++ test/ruby/test_ractor.rb | 28 ++++++ thread.c | 57 ++++++----- vm.c | 21 +++-- vm_core.h | 3 + 8 files changed, 191 insertions(+), 59 deletions(-) diff --git a/cont.c b/cont.c index 7d3aba3093d145..df587ead63c2cd 100644 --- a/cont.c +++ b/cont.c @@ -2124,10 +2124,18 @@ static const rb_data_type_t rb_fiber_data_type = { 0, 0, RUBY_TYPED_FREE_IMMEDIATELY }; +static VALUE fiber_alloc_in(VALUE klass, void *objspace); + static VALUE fiber_alloc(VALUE klass) { - VALUE obj = TypedData_Wrap_Struct(klass, &rb_fiber_data_type, 0); + return fiber_alloc_in(klass, GET_RACTOR()->objspace); +} + +static VALUE +fiber_alloc_in(VALUE klass, void *objspace) +{ + VALUE obj = rb_data_typed_object_wrap_in_objspace(objspace, klass, 0, &rb_fiber_data_type); rb_gc_declare_weak_references(obj); return obj; } @@ -2701,10 +2709,10 @@ rb_threadptr_root_fiber_setup(rb_thread_t *th) } void -rb_root_fiber_obj_setup(rb_thread_t *th) +rb_root_fiber_obj_setup(rb_thread_t *th, void *objspace) { rb_fiber_t *fiber = th->ec->fiber_ptr; - VALUE fiber_value = fiber_alloc(rb_cFiber); + VALUE fiber_value = fiber_alloc_in(rb_cFiber, objspace); DATA_PTR(fiber_value) = fiber; fiber->cont.self = fiber_value; } diff --git a/ext/-test-/gvl/call_without_gvl/call_without_gvl.c b/ext/-test-/gvl/call_without_gvl/call_without_gvl.c index 97946e925d3059..178b050863d716 100644 --- a/ext/-test-/gvl/call_without_gvl/call_without_gvl.c +++ b/ext/-test-/gvl/call_without_gvl/call_without_gvl.c @@ -68,6 +68,31 @@ thread_ubf_async_safe(VALUE thread, VALUE notify_fd) return Qnil; } +/* Churn the malloc accounting from a thread that released the GVL but kept its EC. + * The block is over GC_MALLOC_INCREASE_LOCAL_THRESHOLD so every free reaches the + * objspace instead of the thread-local counter; freeing it sized keeps that true + * where malloc_usable_size is unavailable. */ +#define XFREE_LOOP_SIZE (16 * 1024) + +static void * +xfree_loop(void *p) +{ + for (long i = *(long *)p; i > 0; i--) { + ruby_xfree_sized(ruby_xmalloc(XFREE_LOOP_SIZE), XFREE_LOOP_SIZE); + } + return NULL; +} + +static VALUE +thread_xfree_without_gvl(VALUE klass, VALUE count) +{ + long n = NUM2LONG(count); + + rb_thread_call_without_gvl(xfree_loop, &n, RUBY_UBF_IO, NULL); + + return Qnil; +} + void Init_call_without_gvl(void) { @@ -75,4 +100,5 @@ Init_call_without_gvl(void) VALUE klass = rb_define_module_under(mBug, "Thread"); rb_define_singleton_method(klass, "runnable_sleep", thread_runnable_sleep, 1); rb_define_singleton_method(klass, "ubf_async_safe", thread_ubf_async_safe, 1); + rb_define_singleton_method(klass, "xfree_without_gvl", thread_xfree_without_gvl, 1); } diff --git a/gc.c b/gc.c index 793e02b0503d7a..b12346fe38d795 100644 --- a/gc.c +++ b/gc.c @@ -1129,20 +1129,17 @@ gc_newobj_hook(VALUE obj) RB_GC_VM_UNLOCK_NO_BARRIER(lev); } -ALWAYS_INLINE(static VALUE newobj_body(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size)); +ALWAYS_INLINE(static VALUE newobj_body(rb_ractor_t *cr, void *objspace, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size)); /* The allocation body shared by rb_newobj and rb_ec_newobj_of, forced inline into * both: left to this big translation unit's inline budget, gcc drops it from one * entry point or the other and that allocation path grows a call. */ static VALUE -newobj_body(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size) +newobj_body(rb_ractor_t *cr, void *objspace, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size) { GC_ASSERT((flags & FL_WB_PROTECTED) == 0); - rb_ractor_t *cr = rb_ec_ractor_ptr(ec); - /* Use cr->objspace directly: rb_gc_get_objspace() would look cr up through TLS - * on every allocation. */ size_t actual_alloc_size; - VALUE obj = rb_gc_impl_new_obj(cr->objspace, cr->newobj_cache, klass, flags, wb_protected, size, &actual_alloc_size); + VALUE obj = rb_gc_impl_new_obj(objspace, cr->newobj_cache, klass, flags, wb_protected, size, &actual_alloc_size); GC_ASSERT(actual_alloc_size >= size); shape_id = rb_shape_transition_slot_size(shape_id, actual_alloc_size); @@ -1177,7 +1174,30 @@ newobj_body(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t sha VALUE rb_newobj(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size) { - return newobj_body(ec, klass, flags, shape_id, wb_protected, size); + /* Read the Ractor's slot directly: rb_gc_get_objspace() would look cr up through + * TLS on every allocation. */ + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + return newobj_body(cr, cr->objspace, klass, flags, shape_id, wb_protected, size); +} + +/* Build the object in a named objspace rather than the current Ractor's. The only + * foreign objspace allowed is the child this Ractor is building + * (create_ractor_alloc_thread), whose wrappers must be objects the child owns. + * + * That target has no thread of its own yet, which is what makes this cheap: nothing + * allocates, sweeps or collects there, so the half-built objects need no root and its + * GC can be suppressed outright. Aiming at a live Ractor's objspace -- to build a + * copy where it will be used, say -- needs three things this does not have: a root the + * target's own GC marks the objects under construction from, a newobj_cache paired + * with that objspace (today the only multi-objspace collector has no per-Ractor cache, + * and the ones that do are single-objspace), and write barriers aimed at the target. + * The assertion stops the shortcut. */ +static VALUE +rb_newobj_in_objspace(rb_execution_context_t *ec, void *objspace, VALUE klass, VALUE flags, shape_id_t shape_id, bool wb_protected, size_t size) +{ + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + RUBY_ASSERT(objspace == cr->objspace || objspace == cr->creating_child_objspace); + return newobj_body(cr, objspace, klass, flags, shape_id, wb_protected, size); } VALUE @@ -1191,7 +1211,8 @@ rb_ec_newobj_of(rb_execution_context_t *ec, VALUE klass, VALUE flags, size_t siz RUBY_ASSERT(type != T_ICLASS); (void)type; - return newobj_body(ec, klass, flags, ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_OTHER, true, size); + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + return newobj_body(cr, cr->objspace, klass, flags, ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_OTHER, true, size); } static VALUE @@ -1318,12 +1339,12 @@ rb_data_object_check(VALUE klass) #define RTYPEDDATA_EMBEDDABLE_P(obj) RB_DATA_TYPE_EMBEDDABLE_P(RTYPEDDATA_TYPE(obj)) static VALUE -typed_data_alloc(VALUE klass, VALUE typed_flag, void *datap, const rb_data_type_t *type, size_t size) +typed_data_alloc_in(void *objspace, VALUE klass, VALUE typed_flag, void *datap, const rb_data_type_t *type, size_t size) { RBIMPL_NONNULL_ARG(type); if (klass) rb_data_object_check(klass); bool wb_protected = (type->flags & RUBY_FL_WB_PROTECTED) || !type->function.dmark; - VALUE obj = rb_newobj(GET_EC(), klass, T_DATA, ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_RDATA, wb_protected, size); + VALUE obj = rb_newobj_in_objspace(GET_EC(), objspace, klass, T_DATA, ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_RDATA, wb_protected, size); rb_gc_register_pinning_obj(obj); @@ -1335,18 +1356,30 @@ typed_data_alloc(VALUE klass, VALUE typed_flag, void *datap, const rb_data_type_ return obj; } -VALUE -rb_data_typed_object_wrap(VALUE klass, void *datap, const rb_data_type_t *type) +static VALUE +typed_data_wrap_in(void *objspace, VALUE klass, void *datap, const rb_data_type_t *type) { if (UNLIKELY(RB_DATA_TYPE_EMBEDDABLE_P(type))) { rb_raise(rb_eTypeError, "Cannot wrap an embeddable TypedData"); } - return typed_data_alloc(klass, 0, datap, type, sizeof(struct RTypedData)); + return typed_data_alloc_in(objspace, klass, 0, datap, type, sizeof(struct RTypedData)); } VALUE -rb_data_typed_object_zalloc(VALUE klass, size_t size, const rb_data_type_t *type) +rb_data_typed_object_wrap(VALUE klass, void *datap, const rb_data_type_t *type) +{ + return typed_data_wrap_in(rb_ec_ractor_ptr(GET_EC())->objspace, klass, datap, type); +} + +VALUE +rb_data_typed_object_wrap_in_objspace(void *objspace, VALUE klass, void *datap, const rb_data_type_t *type) +{ + return typed_data_wrap_in(objspace, klass, datap, type); +} + +static VALUE +typed_data_zalloc_in(void *objspace, VALUE klass, size_t size, const rb_data_type_t *type) { if (RB_DATA_TYPE_EMBEDDABLE_P(type)) { if (!(type->flags & (RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_THREAD_SAFE_FREE))) { @@ -1355,17 +1388,29 @@ rb_data_typed_object_zalloc(VALUE klass, size_t size, const rb_data_type_t *type size_t embed_size = offsetof(struct RTypedData, data) + size; if (rb_gc_size_allocatable_p(embed_size)) { - VALUE obj = typed_data_alloc(klass, TYPED_DATA_EMBEDDED, 0, type, embed_size); + VALUE obj = typed_data_alloc_in(objspace, klass, TYPED_DATA_EMBEDDED, 0, type, embed_size); memset((char *)obj + offsetof(struct RTypedData, data), 0, size); return obj; } } - VALUE obj = typed_data_alloc(klass, 0, NULL, type, sizeof(struct RTypedData)); + VALUE obj = typed_data_alloc_in(objspace, klass, 0, NULL, type, sizeof(struct RTypedData)); DATA_PTR(obj) = xcalloc(1, size); return obj; } +VALUE +rb_data_typed_object_zalloc(VALUE klass, size_t size, const rb_data_type_t *type) +{ + return typed_data_zalloc_in(rb_ec_ractor_ptr(GET_EC())->objspace, klass, size, type); +} + +VALUE +rb_data_typed_object_zalloc_in_objspace(void *objspace, VALUE klass, size_t size, const rb_data_type_t *type) +{ + return typed_data_zalloc_in(objspace, klass, size, type); +} + static size_t ruby_xmalloc_usable_size(void *ptr) { @@ -5366,22 +5411,33 @@ rb_objspace_gc_disable(void *objspace) return RBOOL(disabled); } +VALUE +rb_gc_objspace_enable(void *objspace) +{ + return rb_objspace_gc_enable(objspace); +} + VALUE rb_gc_local_enable(void) { - return rb_objspace_gc_enable(rb_gc_get_objspace()); + return rb_gc_objspace_enable(rb_gc_get_objspace()); } VALUE -rb_gc_local_disable_no_rest(void) +rb_gc_objspace_disable_no_rest(void *objspace) { - void *objspace = rb_gc_get_objspace(); bool disabled = !rb_gc_impl_gc_enabled_p(objspace); rb_gc_impl_gc_disable(objspace, false); return RBOOL(disabled); } +VALUE +rb_gc_local_disable_no_rest(void) +{ + return rb_gc_objspace_disable_no_rest(rb_gc_get_objspace()); +} + static VALUE gc_enable(rb_execution_context_t *ec, VALUE _) { diff --git a/internal/gc.h b/internal/gc.h index 19202be1232863..55b2c965b97db5 100644 --- a/internal/gc.h +++ b/internal/gc.h @@ -308,6 +308,13 @@ bool rb_gc_multi_objspace_p(void); bool rb_gc_obj_foreign_p(VALUE obj); void *rb_gc_objspace_alloc(void); void rb_gc_objspace_retire_gc(void); +/* Build an object, or suppress GC, in a named objspace rather than the current + * Ractor's. Only create_ractor_alloc_thread() needs these. */ +VALUE rb_data_typed_object_wrap_in_objspace(void *objspace, VALUE klass, void *datap, const rb_data_type_t *type); +VALUE rb_data_typed_object_zalloc_in_objspace(void *objspace, VALUE klass, size_t size, const rb_data_type_t *type); +VALUE rb_gc_objspace_disable_no_rest(void *objspace); +VALUE rb_gc_objspace_enable(void *objspace); + void rb_gc_objspace_retire(void **objspace_slot); void rb_gc_objspace_postmortem_self(void); void rb_gc_objspace_absorb_into_current(void **objspace_slot); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index e435d0856c72bf..f0da141472842d 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -563,6 +563,34 @@ def test_stillborn_ractor_gc RUBY end + # A thread that released the GVL keeps its EC, so it still resolves an objspace to + # charge its frees to. It must not be sent to the objspace of a child Ractor being + # built by another thread of the same Ractor: a stillborn child frees that objspace. + def test_stillborn_ractor_with_free_off_gvl + assert_ractor(<<~'RUBY', require: '-test-/gvl/call_without_gvl', timeout: 60) + x = 42 # capturing an outer local makes Ractor.new raise IsolationError + stop = false + ready = Queue.new + freers = 4.times.map do + Thread.new do + ready << :up + Bug::Thread.xfree_without_gvl(2_000) until stop + end + end + freers.size.times { ready.pop } # all of them are churning before we start + 2_000.times do + begin + Ractor.new { x } + rescue Ractor::IsolationError + end + end + stop = true + freers.each(&:join) + GC.start + GC.verify_internal_consistency + RUBY + end + # Moving a CoW shared-root String must not steal its buffer (regression guard for the # remaining sharers reading freed memory). def test_move_shared_root_string_keeps_buffer diff --git a/thread.c b/thread.c index 8310529a38b941..309c409bc21582 100644 --- a/thread.c +++ b/thread.c @@ -1134,49 +1134,48 @@ rb_thread_create(VALUE (*fn)(void *), void *arg) static VALUE create_ractor_alloc_thread(rb_ractor_t *r, rb_ractor_t *cr, rb_execution_context_t *ec) { - /* Allocate the child's main Thread and root Fiber wrappers directly in the child's - * objspace, so the thread is built of objects it owns. Whole-VM walks read - * cr->objspace: swap it under the VM lock, unobservable to others. */ + /* Build the child's main Thread and root Fiber wrappers in the child's objspace, + * so the thread is made of objects it owns. Hand that objspace down rather than + * pointing cr->objspace at it: threads holding no GVL read that slot to charge + * their frees, and one of them must never be sent to a heap a stillborn child is + * about to free. + * + * The child's objspace is not in vm->ractor.set yet, so cover it through its + * creator before the first allocation and keep the cover until vm_insert_ractor + * clears it under the VM lock. One slot suffices: one Ractor creates children + * serially. */ + void *const child_objspace = r->objspace; volatile VALUE thval = Qundef; const bool multi_objspace = rb_gc_multi_objspace_p(); enum ruby_tag_type alloc_state = TAG_NONE; RB_VM_LOCKING() { - void *const parent_objspace = cr->objspace; - if (multi_objspace) cr->objspace = r->objspace; - /* The wrapper allocations must not re-enter GC: while cr->objspace points at - * the child, the creator's own objspace is invisible to every walk, so a global - * GC would skip it and leave stale mark bits (a UAF). Single allocations; - * suppressing GC costs only a little growth. */ - VALUE gc_was_disabled = rb_gc_local_disable_no_rest(); - /* The alloc can raise NoMemoryError; a longjmp here would skip both the unlock - * of RB_VM_LOCKING and the objspace restore, so catch and rethrow outside. */ + if (multi_objspace) { + RUBY_ASSERT(cr->creating_child_objspace == NULL); + cr->creating_child_objspace = child_objspace; + } + /* Suppress the child's GC, not the creator's: a cycle here would collect a + * half-built child. Single allocations; this costs only a little growth. */ + VALUE gc_was_disabled = rb_gc_objspace_disable_no_rest(child_objspace); + /* The alloc can raise NoMemoryError; a longjmp here would skip the unlock of + * RB_VM_LOCKING, so catch and rethrow outside. */ EC_PUSH_TAG(ec); if ((alloc_state = EC_EXEC_TAG()) == TAG_NONE) { - thval = rb_thread_alloc(rb_cThread); + thval = rb_thread_alloc_in_objspace(rb_cThread, child_objspace); } EC_POP_TAG(); - if (gc_was_disabled == Qfalse) rb_gc_local_enable(); - if (multi_objspace) cr->objspace = parent_objspace; - /* The child's objspace holds the wrappers but is not in vm->ractor.set yet: - * keep it enumerable until vm_insert_ractor clears this under the VM lock. One - * slot suffices: the GVL is never released between set and clear and one - * Ractor creates children serially, so no overwrite (asserted: releasing the - * GVL here in the future would break it). */ - if (alloc_state == TAG_NONE && multi_objspace) { - RUBY_ASSERT(cr->creating_child_objspace == NULL); - cr->creating_child_objspace = r->objspace; - } - } - if (alloc_state != TAG_NONE) { - /* No cover was set; park the child objspace for the orphan merge and re-raise. */ - RB_VM_LOCKING() { + if (gc_was_disabled == Qfalse) rb_gc_objspace_enable(child_objspace); + if (alloc_state != TAG_NONE) { + /* Drop the cover and park the objspace in this same section: between two of + * them another Ractor's global GC would find a populated objspace that is + * neither covered nor a zombie. */ + if (multi_objspace) cr->creating_child_objspace = NULL; if (r->objspace) { rb_gc_objspace_disown(r->objspace); r->objspace = NULL; } } - EC_JUMP_TAG(ec, alloc_state); } + if (alloc_state != TAG_NONE) EC_JUMP_TAG(ec, alloc_state); return thval; } diff --git a/vm.c b/vm.c index 23bdefdabd5527..c9d8ce752a86d8 100644 --- a/vm.c +++ b/vm.c @@ -3929,7 +3929,7 @@ rb_execution_context_mark(const rb_execution_context_t *ec) void rb_fiber_mark_self(rb_fiber_t *fib); void rb_fiber_update_self(rb_fiber_t *fib); void rb_threadptr_root_fiber_setup(rb_thread_t *th); -void rb_root_fiber_obj_setup(rb_thread_t *th); +void rb_root_fiber_obj_setup(rb_thread_t *th, void *objspace); void rb_threadptr_root_fiber_release(rb_thread_t *th); static void @@ -4083,10 +4083,9 @@ rb_obj_is_thread(VALUE obj) } static VALUE -thread_alloc(VALUE klass) +thread_alloc(VALUE klass, void *objspace) { - rb_thread_t *th; - return TypedData_Make_Struct(klass, rb_thread_t, &thread_data_type, th); + return rb_data_typed_object_zalloc_in_objspace(objspace, klass, sizeof(rb_thread_t), &thread_data_type); } void @@ -4198,16 +4197,22 @@ th_init(rb_thread_t *th, VALUE self, rb_vm_t *vm) } VALUE -rb_thread_alloc(VALUE klass) +rb_thread_alloc_in_objspace(VALUE klass, void *objspace) { - VALUE self = thread_alloc(klass); + VALUE self = thread_alloc(klass, objspace); rb_thread_t *target_th = rb_thread_ptr(self); target_th->ractor = GET_RACTOR(); th_init(target_th, self, target_th->vm = GET_VM()); - rb_root_fiber_obj_setup(target_th); + rb_root_fiber_obj_setup(target_th, objspace); return self; } +VALUE +rb_thread_alloc(VALUE klass) +{ + return rb_thread_alloc_in_objspace(klass, GET_RACTOR()->objspace); +} + #define REWIND_CFP(expr) do { \ rb_execution_context_t *ec__ = GET_EC(); \ VALUE *const curr_sp = (ec__->cfp++)->sp; \ @@ -4822,7 +4827,7 @@ Init_VM(void) th->top_wrapper = 0; th->top_self = rb_vm_top_self(); - rb_root_fiber_obj_setup(th); + rb_root_fiber_obj_setup(th, th->ractor->objspace); rb_vm_register_global_object((VALUE)iseq); th->ec->cfp->_iseq = iseq; diff --git a/vm_core.h b/vm_core.h index 75d6323236169a..f2e41d633a4083 100644 --- a/vm_core.h +++ b/vm_core.h @@ -2022,6 +2022,9 @@ VM_BH_FROM_PROC(VALUE procval) /* VM related object allocate functions */ VALUE rb_thread_alloc(VALUE klass); +/* Build the Thread out of objects a named objspace owns; only a Ractor building its + * child needs this (create_ractor_alloc_thread). */ +VALUE rb_thread_alloc_in_objspace(VALUE klass, void *objspace); VALUE rb_binding_alloc(VALUE klass); VALUE rb_proc_alloc(VALUE klass, enum rb_block_type block_type); VALUE rb_proc_dup(VALUE self); From b926e79a6be02c1aa7d2e6d779afca7fdb347bda Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 25 Aug 2026 16:06:02 +0000 Subject: [PATCH 06/36] Ractor: do not republish port queues a reap freed during a table rebuild ractor_add_port grows a full port table by copying it: st_copy(old_tab), then st_insert of the new entry, then swapping the copy in under the Ractor lock and freeing the old table. Both calls allocate, and an allocation is a safepoint, so a global GC can run there. The end of a global GC is where rb_ractor_reap_dead_ports frees the queues of ports whose Ractor::Port went unmarked and ST_DELETEs them -- from r->sync.ports, which is still old_tab. The copy already taken then carries entries the reap deleted, and swapping it in republishes queues that have been freed. The next global GC walks them: ERROR: AddressSanitizer: heap-use-after-free #0 ractor_queue_mark ractor_sync.c #1 ractor_mark_ports_i ractor_sync.c #3 ractor_sync_mark ractor_sync.c #5 rb_ractor_mark_local_roots ractor.c #6 gc_start_global gc/default/default.c Only the owning Ractor inserts into its own table, so a changed entry count means a reap ran; take the copy again when it did. The count is checked after each allocation rather than once at the end: st_copy fills the header before it allocates the entry storage, so a reap in between leaves the copy counting rows it does not have, and st_insert must not be handed a table in that state. The retry is bounded: each failure means the source table lost at least one entry. Reproduced with Ractor.select over many Ractors that finish and are absorbed, with allocation running a global GC underneath (a supervisor loop). A plain build crashes only under load; under ASAN, 2 of 3 runs on master and 0 of 5 with this change. Co-Authored-By: Claude Opus 5 --- ractor_sync.c | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/ractor_sync.c b/ractor_sync.c index c6e4d8a6d31df1..7742e5929bceea 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -536,8 +536,27 @@ ractor_add_port(rb_ractor_t *r, st_data_t id) // The table is full. Rebuild it outside of the ractor lock (mutators // are serialized by the per-ractor GVL) and swap it under the lock // to exclude the readers (other ractors). - st_table *const new_tab = st_copy(old_tab); - st_insert(new_tab, id, (st_data_t)rq); + st_table *new_tab; + + // Those allocations can run a global GC, whose reap frees dead ports and + // drops them from old_tab; a copy taken across one would republish the + // freed queues. Only the owner inserts into its own table, so a changed + // count means a reap ran. Check after each allocation: st_copy fills the + // header before it allocates the entries, so a reap in between leaves the + // copy counting rows it does not have, which st_insert must not be given. + while (1) { + const st_index_t entries = st_table_size(old_tab); + + new_tab = st_copy(old_tab); + + if (st_table_size(old_tab) == entries) { + st_insert(new_tab, id, (st_data_t)rq); + + if (st_table_size(old_tab) == entries) break; + } + + st_free_table(new_tab); + } RACTOR_LOCK(r); { From 80b867e466a46ec878be9916f00569c13c5b2e18 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 8 Sep 2026 18:17:08 +0000 Subject: [PATCH 07/36] Ractor: keep a receive timeout when another port is woken ractor_wait_receive reports a timeout only when nothing claimed the wakeup: a send or an interrupt says by itself what woke the thread, so the clock is read for wakeup_none alone. But a wakeup goes to every waiter of the Ractor, and one meant for another port says nothing about this receive's deadline. A thread waking the Ractor in a loop therefore holds a timed receive past its time indefinitely -- it is stamped, retries, re-registers, and is stamped again before it can ever be handed wakeup_none: target = Ractor::Port.new other = Ractor::Port.new Thread.new { loop { other << 1; other.receive } } target.receive(timeout: 0.3) # does not return Keep the deadline where it belongs. ractor_receive and the selector loop own the timeout already; let them see that it passed and stop, after one more look so that a message landing right at the deadline is still returned. Co-Authored-By: Claude Opus 5 --- ractor_sync.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ractor_sync.c b/ractor_sync.c index 7742e5929bceea..0ee955054861e0 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1460,6 +1460,14 @@ ractor_check_received(rb_ractor_t *cr, struct ractor_queue *messages) // Returns false if the deadline `end` passed with nothing to deliver. Incoming // messages are delivered even then, so the caller retries its queue once more. +// A wait can end on a wakeup meant for another port, so the caller keeps the +// deadline: a stream of them must not hold a timed receive past its time. +static bool +ractor_deadline_passed_p(const rb_hrtime_t *end) +{ + return end != NULL && rb_hrtime_now() >= *end; +} + static bool ractor_wait_receive(rb_execution_context_t *ec, rb_ractor_t *cr, const rb_hrtime_t *end) { @@ -1544,6 +1552,11 @@ ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp, const r else if (!ractor_wait_receive(ec, cr, end)) { return Qundef; } + else if (ractor_deadline_passed_p(end)) { + // The wait ended on a wakeup meant for another port, which says + // nothing about the clock. One more look, then the deadline stands. + return ractor_try_receive(ec, cr, rp); + } } } @@ -1851,6 +1864,10 @@ ractor_selector__wait(rb_execution_context_t *ec, VALUE selector, const rb_hrtim else if (!ractor_wait_receive(ec, cr, end)) { return Qnil; } + else if (ractor_deadline_passed_p(end)) { + st_foreach(s->ports, ractor_selector_wait_i, (st_data_t)&data); + return data.found ? rb_ary_new_from_args(2, data.rpv, data.v) : Qnil; + } } } From 9bfe1fcdb3b02d9075cc98d2ee65b12fa0e08472 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Tue, 8 Sep 2026 18:18:03 +0000 Subject: [PATCH 08/36] Ractor: wake a receiver when its port is closed Ractor::Port#close left a receiver already waiting on that port asleep: an untimed receive never returned, and a timed one waited out its whole timeout and reported nil, where closing a port a thread waits on should stop that thread. Closing a port means nothing more can arrive on it, which is the kind of news a send delivers by waking every waiter. Do the same, with wakeup_by_close -- the status already spelled out in the enum and commented out. The woken thread needs no new logic: ractor_close_port deletes an emptied port from the table, and ractor_try_receive already raises Ractor::ClosedError for a port that is no longer there. A port closed with messages still queued keeps them: it stays in the table until a receiver drains it, and the last message takes it away. Only a close that closed a port wakes anyone: ractor_queue_close reports whether it was the call that closed the queue, so re-closing a closed port is news to nobody. Ractor.select over a port closed while it sleeps now raises Ractor::ClosedError, as selecting an already-closed port does; it used to sleep on. Co-Authored-By: Claude Opus 5 --- bootstraptest/test_ractor.rb | 28 ++++++++++++++++++++++++++++ ractor.rb | 16 +++++++++++++++- ractor_core.h | 2 +- ractor_sync.c | 18 +++++++++++++++--- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/bootstraptest/test_ractor.rb b/bootstraptest/test_ractor.rb index c674ebc2bae9b9..e263f34f09ac66 100644 --- a/bootstraptest/test_ractor.rb +++ b/bootstraptest/test_ractor.rb @@ -2863,3 +2863,31 @@ def st.m; :strct end 6.times { GC.start } [taken.map(&:receive), [closed.receive, closed.receive]] } + +# Closing a port wakes a receiver waiting on it, with or without a timeout. +assert_equal '[:closed, :closed]', %q{ + untimed = Ractor::Port.new + th = Thread.new do + begin + untimed.receive + rescue Ractor::ClosedError + :closed + end + end + Thread.pass until th.status == 'sleep' + untimed.close + untimed_result = th.value # before the next close, which would wake it too + + timed = Ractor::Port.new + th2 = Thread.new do + begin + timed.receive(timeout: 10) + rescue Ractor::ClosedError + :closed + end + end + Thread.pass until th2.status == 'sleep' + timed.close + + [untimed_result, th2.value] +} diff --git a/ractor.rb b/ractor.rb index e826e61b37655a..67d96cbbd112ee 100644 --- a/ractor.rb +++ b/ractor.rb @@ -297,6 +297,9 @@ def self.count # # r1 done # # r0 done # + # Closing one of the given ports raises Ractor::ClosedError, whether it was + # closed before the call or while it waits. + # # The following example is almost equivalent to ractors.map(&:value) except the thread # is unblocked when any of the ractors has terminated as opposed to waiting for their termination in # the array element order. @@ -759,12 +762,18 @@ class Port # is already there and returns +nil+ otherwise. # # If the port is closed and there are no more messages in the message queue, - # the method raises Ractor::ClosedError. + # the method raises Ractor::ClosedError. Closing a port while this method + # waits on it ends the wait the same way; messages queued before the close + # are received first. # # port = Ractor::Port.new # port.close # port.receive #=> raise Ractor::ClosedError # + # port = Ractor::Port.new + # Thread.new { sleep 0.1; port.close } + # port.receive #=> raise Ractor::ClosedError, after 0.1 seconds + # def receive(timeout: nil) __builtin_cexpr! %q{ ractor_port_receive(ec, self, timeout) @@ -823,6 +832,11 @@ def send obj, move: false # Closes the port. Sending to a closed port is prohibited. # Receiving is also prohibited if there are no messages in its message queue. # + # Messages already in the queue are kept: a receiver takes them before the + # port reports itself closed. A Ractor::Port#receive waiting on the port when + # it closes stops there and raises Ractor::ClosedError, rather than waiting + # for a message that can no longer arrive. + # # Only the Ractor which created the port is allowed to close it. # # port = Ractor::Port.new diff --git a/ractor_core.h b/ractor_core.h index 6a545251473dc5..a3e2ce2f54af95 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -173,7 +173,7 @@ enum ractor_wakeup_status { wakeup_none, wakeup_by_send, wakeup_by_interrupt, - // wakeup_by_close, + wakeup_by_close, }; struct ractor_waiter { diff --git a/ractor_sync.c b/ractor_sync.c index 0ee955054861e0..3cc61f52d19706 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -428,10 +428,13 @@ ractor_queue_size(const struct ractor_queue *rq) return size; } -static void +// Returns whether this call is the one that closed it. +static bool ractor_queue_close(struct ractor_queue *rq) { + bool closed_now = !rq->closed; rq->closed = true; + return closed_now; } static void @@ -631,18 +634,21 @@ ractor_closed_port_p(rb_execution_context_t *ec, rb_ractor_t *r, const struct ra static void ractor_deliver_incoming_messages(rb_execution_context_t *ec, rb_ractor_t *cr); static bool ractor_queue_empty_p(rb_ractor_t *r, const struct ractor_queue *rq); +static bool ractor_wakeup_all(rb_ractor_t *r, enum ractor_wakeup_status wakeup_status); + static bool ractor_close_port(rb_execution_context_t *ec, rb_ractor_t *cr, const struct ractor_port *rp) { VM_ASSERT(cr == rp->r); struct ractor_queue *rq = NULL; + bool closed_now = false; RACTOR_LOCK_SELF(cr); { ractor_deliver_incoming_messages(ec, cr); // check incoming messages if (st_lookup(rp->r->sync.ports, ractor_port_id(rp), (st_data_t *)&rq)) { - ractor_queue_close(rq); + closed_now = ractor_queue_close(rq); if (ractor_queue_empty_p(cr, rq)) { // delete from the table @@ -654,6 +660,12 @@ ractor_close_port(rb_execution_context_t *ec, rb_ractor_t *cr, const struct ract } RACTOR_UNLOCK_SELF(cr); + if (closed_now) { + // Only when this call closed it: waking the Ractor is not free to the + // other waiters, and a re-close of a closed port is news to nobody. + ractor_wakeup_all(cr, wakeup_by_close); + } + return rq != NULL; } @@ -1300,7 +1312,7 @@ wakeup_status_str(enum ractor_wakeup_status wakeup_status) case wakeup_none: return "none"; case wakeup_by_send: return "by_send"; case wakeup_by_interrupt: return "by_interrupt"; - // case wakeup_by_close: return "by_close"; + case wakeup_by_close: return "by_close"; } rb_bug("unreachable"); } From 07fb082bc27a6ec33de9b2ebe09943fdaecb62c5 Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Tue, 8 Sep 2026 13:12:53 -0700 Subject: [PATCH 09/36] ZJIT: Remove the survives method (#18395) We can use "covers" instead of "survives" when we need to spill across calls --- zjit/src/backend/lir.rs | 58 ++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/zjit/src/backend/lir.rs b/zjit/src/backend/lir.rs index 3ab73cfcd9307b..c6f2bd0d025a44 100644 --- a/zjit/src/backend/lir.rs +++ b/zjit/src/backend/lir.rs @@ -1485,13 +1485,6 @@ impl Interval { self.end() <= pos } - /// Check if the interval is alive at position - /// Panics if the range is not set - pub fn survives(&self, position: usize) -> bool { - assert!(self.ranges.len() > 0, "survives called on interval with no range"); - self.ranges.iter().any(|range| range.from < position && position < range.to) - } - /// Returns true if position falls inside one of the ranges in this /// interval. pub fn covers(&self, position: usize) -> bool { @@ -2703,19 +2696,28 @@ impl Assembler HashSet::default() }; - // Find survivors: intervals that survive this Call instruction - // We need to preserve the "surviving" registers past the ccall, - // so we're going to push them all on the stack, then pop - // after we make the ccall + // Find survivors: intervals that are live across this Call + // instruction. We need to preserve the "surviving" registers + // past the ccall, so we're going to push them all on the + // stack, then pop after we make the ccall + let out_vreg_id = out.is_vreg().then(|| out.vreg_idx()); + debug_assert!( + out_vreg_id.is_none_or(|id| !intervals[id].has_bounds() || intervals[id].born_at(insn_number)), + "a CCall's output interval must start at the CCall" + ); let survivors: Vec = intervals.iter() .filter(|interval| { // We need to spill register intervals on this CCall in two cases: - // 1) The VReg is referenced in an instruction after the CCall - let survives_call = interval.has_bounds() && interval.survives(insn_number); + // 1) The VReg is live across the CCall. The VReg this CCall + // defines is not one of them: its range starts here, so it + // holds no value yet and there is nothing to preserve. + let live_across_call = Some(interval.vreg_id) != out_vreg_id + && interval.covers(insn_number); + // 2) The VReg is referenced by the stack map for the CCall let stack_map_reg = stack_vreg_ids.contains(&interval.vreg_id); let is_register = interval.assigned.get().and_then(|alloc| alloc.alloc_pool_index(alloc_regs)).is_some(); - is_register && (survives_call || stack_map_reg) + is_register && (live_across_call || stack_map_reg) }) .map(|interval| interval.vreg_id) .collect(); @@ -4778,12 +4780,12 @@ mod tests { assert_eq!(interval.end(), 25); // The vreg is not live inside the hole ... - assert!(!interval.survives(10)); - assert!(!interval.survives(15)); + assert!(!interval.covers(10)); + assert!(!interval.covers(15)); // ... but the interval is not over, so it must keep its register. assert!(interval.end() > 15); // ... and it is live again on the far side. - assert!(interval.survives(22)); + assert!(interval.covers(22)); // A range that abuts the last one merges into it. interval.add_range(25, 30); @@ -4809,15 +4811,15 @@ mod tests { } #[test] - fn test_interval_survives() { + fn test_interval_covers() { let mut interval = Interval::new(VRegId(1)); interval.add_range(3, 10); - assert!(!interval.survives(2)); // Before range - assert!(!interval.survives(3)); // At start (exclusive) - assert!(interval.survives(5)); // Inside range - assert!(!interval.survives(10)); // At end (exclusive) - assert!(!interval.survives(11)); // After range + assert!(!interval.covers(2)); // Before range + assert!(interval.covers(3)); // At start (inclusive: the def position) + assert!(interval.covers(5)); // Inside range + assert!(!interval.covers(10)); // At end (exclusive) + assert!(!interval.covers(11)); // After range } #[test] @@ -4829,7 +4831,6 @@ mod tests { // so position 11 belongs to no instruction. interval.set_from(10); assert_eq!(interval.ranges, vec![LiveRange { from: 10, to: 11 }]); - assert!(!interval.survives(10)); assert!(interval.is_dead()); // With existing range, updates start but keeps end @@ -4863,13 +4864,6 @@ mod tests { interval.add_range(10, 5); } - #[test] - #[should_panic(expected = "survives called on interval with no range")] - fn test_interval_survives_panics_without_range() { - let interval = Interval::new(VRegId(1)); - interval.survives(5); - } - #[test] fn test_build_intervals() { let TestFunc { mut asm, r10, r11, r12, r13, r14, r15, .. } = build_func(); @@ -4906,7 +4900,7 @@ mod tests { ]); assert_eq!(intervals[r12_idx].start(), 20); assert_eq!(intervals[r12_idx].end(), 38); - assert!(!intervals[r12_idx].survives(32)); + assert!(!intervals[r12_idx].covers(32)); assert_eq!(intervals[r13_idx].ranges, vec![LiveRange { from: 20, to: 32 }]); From fead5aefbae83edbc614a6c71638e1ff853a19d7 Mon Sep 17 00:00:00 2001 From: Takashi Kokubun Date: Tue, 8 Sep 2026 14:27:50 -0700 Subject: [PATCH 10/36] Add Jean as an ERB maintainer and update the list of active maintainers. We don't seem to enumerate all past maintainers for other gems in this file. Context: https://github.com/ruby/erb/pull/136#issuecomment-5588630696 --- doc/maintainers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/maintainers.md b/doc/maintainers.md index 494716c6dba5e0..1c4ec8896ae722 100644 --- a/doc/maintainers.md +++ b/doc/maintainers.md @@ -148,8 +148,8 @@ consensus on ruby-core/ruby-dev. #### lib/erb.rb -* Masatoshi SEKI ([seki]) * Takashi Kokubun ([k0kubun]) +* Jean Boussier ([byroot]) * https://github.com/ruby/erb * https://rubygems.org/gems/erb From 2304205b6db97111f2aba63360da18b3ffada1e3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 7 Sep 2026 10:13:02 +0900 Subject: [PATCH 11/36] Keep POSTLINK a valid command on darwin The darwin branch starts from an empty POSTLINK to build up the dsymutil and codesign steps, so it stays empty when neither tool is found, unlike the `:` default of the other platforms. A recipe that runs it as part of a compound command needs it to be a command. Co-Authored-By: Claude Fable 5.1 --- configure.ac | 1 + 1 file changed, 1 insertion(+) diff --git a/configure.ac b/configure.ac index fdb92a6f04d9fe..0e7c323a137595 100644 --- a/configure.ac +++ b/configure.ac @@ -1237,6 +1237,7 @@ main() AS_IF([test -n "$dsymutil"], [ POSTLINK="$dsymutil \$@ 2>/dev/null${POSTLINK:+; $POSTLINK}" ]) + : ${POSTLINK:=:} AC_CHECK_HEADERS(crt_externs.h, [], [], [ #include ]) From a25240e51d3c2625838520b8448149b4c8c4f3a2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 7 Sep 2026 10:13:02 +0900 Subject: [PATCH 12/36] Link ruby with static extensions only through exts With --with-static-linked-ext, the archives, libraries and Init functions of the extensions are known only to exts.mk, and the top-level $(PROGRAM) rule linked ruby with a bare EXTOBJS whenever its prerequisites changed. The sub-make from exts.mk relinked it correctly afterwards only because it happened to come later, and because configuring ext/-test- deletes ext/extinit.c. With `make -j` under GNU make 3.81, or 4.4 with `DOT_WAIT=`, `programs` and `test-precheck` ran that link and the exts sub-make at once, leaving a ruby without any extension or encoding, or no ruby at all when the two links collided in codesign. A bare `make ruby` gave the same broken ruby even serially. Now the top-level $(PROGRAM) depends on `exts` instead of linking when PROGRAM_EXTS is set, and only the sub-make, which passes PROGRAM_EXTS empty along with the real EXTOBJS, links. EXTOBJS at the top level is always dmyext.o so that ext/extinit.c is generated only with the full EXTINITS. `.WAIT` stays in the test targets; it still keeps the outputs of the dynamic build from interleaving, but the correctness of the static build no longer depends on it. Co-Authored-By: Claude Fable 5.1 --- common.mk | 2 +- configure.ac | 4 ++-- ext/extmk.rb | 1 + template/Makefile.in | 14 ++++++++++---- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/common.mk b/common.mk index 4fbd986118dbf6..e6559c6ca41160 100644 --- a/common.mk +++ b/common.mk @@ -410,7 +410,7 @@ program: $(SHOWFLAGS) $(DOT_WAIT) $(PROGRAM) wprogram: $(SHOWFLAGS) $(DOT_WAIT) $(WPROGRAM) mini: PHONY miniruby$(EXEEXT) -$(PROGRAM) $(WPROGRAM): $(LIBRUBY) $(MAINOBJ) $(OBJS) $(EXTOBJS) $(SETUP) $(PREP) +$(PROGRAM) $(WPROGRAM): $(LIBRUBY) $(MAINOBJ) $(OBJS) $(EXTOBJS) $(SETUP) $(PREP) $(PROGRAM_EXTS) $(LIBRUBY_A): $(LIBRUBY_A_OBJS) $(MAINOBJ) $(INITOBJS) $(ARCHFILE) diff --git a/configure.ac b/configure.ac index 0e7c323a137595..b09d549f76a7b8 100644 --- a/configure.ac +++ b/configure.ac @@ -3178,15 +3178,15 @@ AC_ARG_WITH(static-linked-ext, [AS_CASE([$withval],[yes],[STATIC=;EXTSTATIC=static],[no],[],[EXTSTATIC="$withval"])]) AS_CASE([",$EXTSTATIC,"], [,static,|*,enc,*], [ ENCOBJS='enc/encinit.$(OBJEXT) enc/libenc.$(LIBEXT) enc/libtrans.$(LIBEXT)' - EXTOBJS='ext/extinit.$(OBJEXT)' AC_DEFINE_UNQUOTED(EXTSTATIC, 1) AC_SUBST(ENCSTATIC, static) ], [ ENCOBJS='dmyenc.$(OBJEXT)' - EXTOBJS='dmyext.$(OBJEXT)' ]) +EXTOBJS='dmyext.$(OBJEXT)' AC_SUBST(ENCOBJS) AC_SUBST(EXTOBJS) +AC_SUBST(PROGRAM_EXTS, [${EXTSTATIC:+exts}]) : "rpath" && { AS_CASE(["$target_os"], diff --git a/ext/extmk.rb b/ext/extmk.rb index 44f86e19734deb..d4127fbfdd4587 100755 --- a/ext/extmk.rb +++ b/ext/extmk.rb @@ -781,6 +781,7 @@ def mf.macro(name, values, max = 70) end submakeopts << 'EXTLDFLAGS="$(EXTLDFLAGS)"' submakeopts << 'EXTINITS="$(EXTINITS)"' + submakeopts << 'PROGRAM_EXTS=' submakeopts << 'SHOWFLAGS=' mf.macro "SUBMAKEOPTS", submakeopts mf.macro "NOTE_MESG", %w[$(RUBY) $(top_srcdir)/tool/lib/colorize.rb skip] diff --git a/template/Makefile.in b/template/Makefile.in index 407600869d8b60..a519dfb858c6ea 100644 --- a/template/Makefile.in +++ b/template/Makefile.in @@ -228,6 +228,7 @@ PREP = @PREP@ ARCHFILE = @ARCHFILE@ SETUP = EXTSTATIC = @EXTSTATIC@ +PROGRAM_EXTS = @PROGRAM_EXTS@ ENCSTATIC = @ENCSTATIC@ SET_LC_MESSAGES = env LC_MESSAGES=C @@ -317,11 +318,16 @@ miniruby$(EXEEXT): $(Q) $(PURIFY) $(CC) $(EXE_LDFLAGS) $(XLDFLAGS) $(NORMALMAINOBJ) $(MINIOBJS) $(COMMONOBJS) $(MAINLIBS) $(OUTFLAG)$@ $(Q) $(POSTLINK) +# With statically linked extensions, only the sub-make from exts.mk, +# which passes the archives, libraries and EXTINITS and empties +# PROGRAM_EXTS, can link $(PROGRAM); the top-level $(PROGRAM) just +# depends on `exts` (see common.mk). $(PROGRAM): - @$(RM) $@ - $(ECHO) linking $@ - $(Q) $(PURIFY) $(CC) $(EXE_LDFLAGS) $(XLDFLAGS) $(MAINOBJ) $(EXTOBJS) $(LIBRUBYARG) $(MAINLIBS) $(EXTLIBS) $(OUTFLAG)$@ - $(Q) $(POSTLINK) + $(Q) if test -z "$(PROGRAM_EXTS)"; then \ + $(RM) $@ && $(ECHO0) linking $@ && \ + $(PURIFY) $(CC) $(EXE_LDFLAGS) $(XLDFLAGS) $(MAINOBJ) $(EXTOBJS) $(LIBRUBYARG) $(MAINLIBS) $(EXTLIBS) $(OUTFLAG)$@ && \ + { $(POSTLINK); }; \ + fi $(PROGRAM): @XRUBY_LIBPATHENV_WRAPPER@ From 838b176548fbfbb9990c068ef8445bbaf61fb00f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 8 Sep 2026 18:23:29 +0900 Subject: [PATCH 13/36] Link ruby with static extensions only through exts on mswin win32/Makefile.sub has its own EXTOBJS and its own $(PROGRAM) rule, so PROGRAM_EXTS never reached nmake. With --with-static-linked-ext, `nmake ruby` regenerated ext/extinit.c with no EXTINITS and relinked the DLL with dmyext.obj, leaving a ruby without any extension or encoding. Co-Authored-By: Claude Opus 5 --- win32/Makefile.sub | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/win32/Makefile.sub b/win32/Makefile.sub index e9a9c79e300c10..dd303ed9047014 100644 --- a/win32/Makefile.sub +++ b/win32/Makefile.sub @@ -476,13 +476,15 @@ COMMON_HEADERS = winsock2.h ws2tcpip.h windows.h !if "$(EXTSTATIC)" == "static" ENCOBJS = enc/encinit.$(OBJEXT) enc/libenc.lib enc/libtrans.lib -EXTOBJS = ext/extinit.$(OBJEXT) ! if !defined(ENCSTATIC) ENCSTATIC = static ! endif !else ENCOBJS = dmyenc.$(OBJEXT) +!endif EXTOBJS = dmyext.$(OBJEXT) +!if "$(EXTSTATIC)" != "" +PROGRAM_EXTS = exts !endif ext_hdrdir = $(EXTOUT)/include @@ -1149,9 +1151,14 @@ miniruby.rc: !if "$(PROGRAM)" != "" $(PROGRAM): $(MAINOBJ) $(LIBRUBY_SO) $(RUBY_INSTALL_NAME).res +! if "$(PROGRAM_EXTS)" == "" $(ECHO) linking $(@:\=/) $(Q) $(PURIFY) $(CC) $(MAINOBJ) $(EXTOBJS) $(RUBY_INSTALL_NAME).res \ $(OUTFLAG)$@ $(LIBRUBYARG) -link $(LDFLAGS) $(XLDFLAGS) +! else +# without a command nmake would infer $(PROGRAM) from ruby.c + @$(NULLCMD) +! endif !endif !if "$(WPROGRAM)" != "" From d0e0f2af5f09d4e44eacfa64917dc6e85d523ed6 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 8 Sep 2026 19:01:47 +0900 Subject: [PATCH 14/36] [ruby/digest] Match the linkage of rb_digest_wrap_metadata on MSVC digest.h declared it plain while digest.c defines it as RUBY_FUNC_EXPORTED, which cl.exe rejects, so a build with --with-static-linked-ext failed: ext/digest/digest.c(547): error C2375: 'rb_digest_wrap_metadata': redefinition; different linkage Keeping the attribute on the definition is what exports the symbol from libruby when extensions are linked statically. https://github.com/ruby/digest/commit/7f72a1eb43 Co-Authored-By: Claude Opus 5 --- ext/digest/digest.c | 3 ++- ext/digest/digest.h | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ext/digest/digest.c b/ext/digest/digest.c index 28f60227548400..f0f0910de6421b 100644 --- a/ext/digest/digest.c +++ b/ext/digest/digest.c @@ -13,6 +13,7 @@ ************************************************/ +#define RB_DIGEST_WRAP_METADATA_LINKAGE RUBY_FUNC_EXPORTED #include "digest.h" static VALUE rb_mDigest; @@ -543,7 +544,7 @@ static const rb_data_type_t metadata_type = { {0}, }; -RUBY_FUNC_EXPORTED VALUE +RB_DIGEST_WRAP_METADATA_LINKAGE VALUE rb_digest_wrap_metadata(const rb_digest_metadata_t *meta) { return rb_obj_freeze(TypedData_Wrap_Struct(0, &metadata_type, (void *)meta)); diff --git a/ext/digest/digest.h b/ext/digest/digest.h index c5c37583a6e7c9..0ad8df60b93780 100644 --- a/ext/digest/digest.h +++ b/ext/digest/digest.h @@ -73,13 +73,21 @@ rb_id_metadata(void) # define DIGEST_USE_RB_EXT_RESOLVE_SYMBOL 1 #endif +/* Declarations and definitions of the same function must carry the same + * attribute on MSVC, and digest.c has to export the definition so that + * statically linked extensions can find the symbol in libruby. */ +#ifndef RB_DIGEST_WRAP_METADATA_LINKAGE +# define RB_DIGEST_WRAP_METADATA_LINKAGE extern +#endif + static inline VALUE rb_digest_make_metadata(const rb_digest_metadata_t *meta) { #if defined(EXTSTATIC) && EXTSTATIC /* The extension is built as a static library, so safe to refer to * rb_digest_wrap_metadata directly. */ - extern VALUE rb_digest_wrap_metadata(const rb_digest_metadata_t *meta); + RB_DIGEST_WRAP_METADATA_LINKAGE + VALUE rb_digest_wrap_metadata(const rb_digest_metadata_t *meta); return rb_digest_wrap_metadata(meta); #else /* The extension is built as a shared library, so we can't refer From 4899da3c723b5886f6ea995f6d98ccb8435b9af2 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Tue, 8 Sep 2026 11:59:47 -0500 Subject: [PATCH 15/36] [DOC] Harmonize symlink? methods --- file.c | 37 +++++++++++++++++-------------------- pathname_builtin.rb | 18 ++++++++---------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/file.c b/file.c index 7f0967e5c18719..42ffc6147319bc 100644 --- a/file.c +++ b/file.c @@ -1883,18 +1883,16 @@ rb_file_pipe_p(VALUE obj, VALUE fname) * call-seq: * File.symlink?(path) -> true or false * - * Returns whether the entry at `path` is a symbolic link: + * Returns whether the entry at `path` + * is a [symbolic link](rdoc-ref:file/symbolic_links.md): * * ```ruby - * # Create paths. - * file_path = 'doc/extension.rdoc' # => "doc/extension.rdoc" - * target_path = File.join('..', file_path) # => "../doc/extension.rdoc" - * link_path = 'lib/u.tmp' # => "lib/u.tmp" - * File.symlink?(link_path) # => false - * # Create link and verify. - * File.symlink(target_path, link_path) - * File.symlink?(link_path) # => true - * File.delete(link_path) # Clean up. + * filepath = 'README.md' + * linkpath = 'foo' + * File.symlink(filepath, linkpath) + * File.symlink?(filepath) # => false + * File.symlink?(linkpath) # => true + * File.unlink(linkpath) # Clean up. * ``` * */ @@ -6721,18 +6719,17 @@ rb_stat_p(VALUE obj) * call-seq: * symlink? -> true or false * - * Returns whether the entry in `self` is a symbolic link: + * Returns whether the entry in `self` + * is a [symbolic link](rdoc-ref:file/symbolic_links.md): * * ```ruby - * path = 'doc/t.tmp' - * link_path = 'lib/u.tmp' - * File.write(path, 'foo') - * File.symlink(path, link_path) - * File.stat(path).symlink? # => false - * File.stat(link_path).symlink? # Raises Errno::ENOENT; entry is not a file. - * File.lstat(link_path).symlink? # => true - * File.delete(path) - * File.delete(link_path) + * filepath = 'README.md' + * linkpath = 'foo' + * File.symlink(filepath, linkpath) + * File.stat(filepath).symlink? # => false + * File.stat(linkpath).symlink? # => false # stat followed symlink. + * File.lstat(linkpath).symlink? # => true # lstat did not follow symlink. + * File.unlink(linkpath) # Clean up. * ``` * */ diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 39ff95fd3b47c0..d7641cb4a7ae48 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -2661,18 +2661,16 @@ def sticky?() FileTest.sticky?(@path) end # call-seq: # symlink? -> true or false # - # Returns whether the entry at the path in `self` is a symbolic link: + # Returns whether the entry at the path in `self` + # is a [symbolic link](rdoc-ref:file/symbolic_links.md): # # ```ruby - # # Create Pathnames. - # file_pn = Pathname('doc/extension.rdoc') # => # - # target_pn = Pathname('..').join(file_pn) # => # - # link_pn = Pathname('lib/u.tmp') # => # - # link_pn.symlink? # => false - # # Create link. - # link_pn.make_symlink(target_pn) - # link_pn.symlink? # => true - # link_pn.delete # Clean up. + # file_pn = Pathname('README.md') + # link_pn = Pathname('foo') + # link_pn.make_symlink(file_pn) + # file_pn.symlink? # => false + # link_pn.symlink? # => true + # link_pn.unlink # Clean up. # ``` # def symlink?() FileTest.symlink?(@path) end From 2c98ef2f8675e53618a0f189ea88d3384bbc05f4 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 8 Sep 2026 13:08:46 +0900 Subject: [PATCH 16/36] Fix memory leak when moving shared root string When a shared root string is moved to another Ractor, it loses the T_STRING type, which causes it to leak memory. For example, this script leaks memory: r = Ractor.new { loop { Ractor.receive } } 10.times do 100_000.times do str = "x" * 4096 str.instance_variable_set(:@x, []) # make str not shareable str.freeze child_str = str.dup r.send(str, move: true) end puts `ps -o rss= -p #{$$}` end Before: 470516 906212 1303752 1683808 2121396 2502312 2898164 3328072 3697172 4114284 After: 129604 135788 136748 136748 136812 136812 136876 136876 136876 136876 --- ractor.c | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ractor.c b/ractor.c index 6d60d6e6af6869..1f8071790197ae 100644 --- a/ractor.c +++ b/ractor.c @@ -2528,6 +2528,17 @@ move_neutralize_source(VALUE obj) bool wipe_body = true; switch (BUILTIN_TYPE(obj)) { case T_STRING: + if (!STR_EMBED_P(obj) && !rb_str_reembeddable_p(obj)) { + /* A heap (non-embedded), shared root string keeps its buffer because + * other strings reference this shared root. It needs to keep T_STRING + * because otherwise the GC will not free the buffer when this object + * dies which will leak memory. */ + RBASIC_SET_CLASS_RAW(obj, rb_cRactorMovedObject); + RBASIC(obj)->flags |= FL_FREEZE; + RBASIC_SET_FULL_SHAPE_ID(obj, (shape_id & ~SHAPE_ID_LAYOUT_MASK) | SHAPE_ID_LAYOUT_OTHER); + RSTRING(obj)->len = 0; + return; + } wipe_body = !rb_str_embedded_shared_root_p(obj); break; case T_ARRAY: From 2e9c99d5440674460568f25908cfacc30b8d6a55 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 8 Sep 2026 13:10:19 +0900 Subject: [PATCH 17/36] Fix memory leak when moving shared root array When a shared root array is moved to another Ractor, it loses the T_ARRAY type, which causes it to leak memory. For example, this script leaks memory: r = Ractor.new { loop { Ractor.receive } } 10.times do 100_000.times do ary = [1] * 1000 ary.instance_variable_set(:@x, []) # make ary not shareable ary.freeze r.send(ary, move: true) end puts `ps -o rss= -p #{$$}` end Before: 977616 1769680 2552492 3335304 4118116 4900928 5683744 6466640 7249368 8032180 After: 211588 220448 220316 220444 220700 212404 220512 220872 221200 220780 --- ractor.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ractor.c b/ractor.c index 1f8071790197ae..da6fc91ed50130 100644 --- a/ractor.c +++ b/ractor.c @@ -2542,6 +2542,21 @@ move_neutralize_source(VALUE obj) wipe_body = !rb_str_embedded_shared_root_p(obj); break; case T_ARRAY: + if (!ARY_EMBED_P(obj) && !ARY_SHARED_P(obj) && (ARY_SHARED_ROOT_P(obj) || OBJ_FROZEN(obj))) { + /* A heap (non-embedded), shared root array keeps its buffer because + * other arrays reference this shared root. It needs to keep T_ARRAY + * because otherwise the GC will not free the buffer when this object + * dies which will leak memory. */ + RBASIC_SET_CLASS_RAW(obj, rb_cRactorMovedObject); + RBASIC(obj)->flags |= FL_FREEZE; + RBASIC_SET_FULL_SHAPE_ID(obj, (shape_id & ~SHAPE_ID_LAYOUT_MASK) | SHAPE_ID_LAYOUT_OTHER); + if (!ARY_SHARED_ROOT_P(obj)) { + /* Present as empty to stale readers. Not for a shared root: its + * len doubles as the buffer capacity that ARY_HEAP_SIZE frees by. */ + RARRAY(obj)->as.heap.len = 0; + } + return; + } wipe_body = !rb_ary_embedded_shared_root_p(obj); break; default: From c329f36bea47188306d943dd73f4dcb070feb3fc Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Fri, 4 Sep 2026 16:12:51 -0400 Subject: [PATCH 18/36] Add and use rb_gc_update_moved(VALUE *obj) which conditionally assigns Using the `container->obj = rb_gc_location(container->obj)` pattern dirties pages unconditionally. If we're in a forked process, this is bad for CoW. If the object hasn't moved, don't reassign. --- box.c | 24 +++++++-------- cont.c | 8 ++--- gc.c | 13 ++++++-- gc/default/default.c | 2 +- gc/gc.h | 13 ++------ id_table.c | 2 +- imemo.c | 9 +++--- include/ruby/internal/core/rtypeddata.h | 2 +- include/ruby/internal/gc.h | 15 +++++++++- internal/gc.h | 5 ++-- io_buffer.c | 2 +- iseq.c | 2 +- symbol.c | 4 +-- thread_sync.c | 2 +- variable.c | 9 ++---- vm.c | 40 +++++++++++-------------- vm_backtrace.c | 10 +++---- vm_method.c | 6 ++-- weakmap.c | 6 ++-- 19 files changed, 89 insertions(+), 85 deletions(-) diff --git a/box.c b/box.c index d58082e15bb37e..72b4b932225af9 100644 --- a/box.c +++ b/box.c @@ -203,21 +203,21 @@ rb_box_gc_update_references(void *ptr) if (!box) return; if (box->box_object) - box->box_object = rb_gc_location(box->box_object); + rb_gc_update_moved(&box->box_object); if (box->top_self) - box->top_self = rb_gc_location(box->top_self); - box->load_path = rb_gc_location(box->load_path); - box->expanded_load_path = rb_gc_location(box->expanded_load_path); - box->load_path_snapshot = rb_gc_location(box->load_path_snapshot); + rb_gc_update_moved(&box->top_self); + rb_gc_update_moved(&box->load_path); + rb_gc_update_moved(&box->expanded_load_path); + rb_gc_update_moved(&box->load_path_snapshot); if (box->load_path_check_cache) { - box->load_path_check_cache = rb_gc_location(box->load_path_check_cache); + rb_gc_update_moved(&box->load_path_check_cache); } - box->loaded_features = rb_gc_location(box->loaded_features); - box->loaded_features_snapshot = rb_gc_location(box->loaded_features_snapshot); - box->loaded_features_realpaths = rb_gc_location(box->loaded_features_realpaths); - box->loaded_features_realpath_map = rb_gc_location(box->loaded_features_realpath_map); - box->ruby_dln_libmap = rb_gc_location(box->ruby_dln_libmap); - box->gvar_tbl = rb_gc_location(box->gvar_tbl); + rb_gc_update_moved(&box->loaded_features); + rb_gc_update_moved(&box->loaded_features_snapshot); + rb_gc_update_moved(&box->loaded_features_realpaths); + rb_gc_update_moved(&box->loaded_features_realpath_map); + rb_gc_update_moved(&box->ruby_dln_libmap); + rb_gc_update_moved(&box->gvar_tbl); } void diff --git a/cont.c b/cont.c index df587ead63c2cd..0d0f00f60e0f18 100644 --- a/cont.c +++ b/cont.c @@ -1107,9 +1107,9 @@ cont_compact(void *ptr) rb_context_t *cont = ptr; if (cont->self) { - cont->self = rb_gc_location(cont->self); + rb_gc_update_moved(&cont->self); } - cont->value = rb_gc_location(cont->value); + rb_gc_update_moved(&cont->value); rb_execution_context_update(&cont->saved_ec); } @@ -1220,7 +1220,7 @@ void rb_fiber_update_self(rb_fiber_t *fiber) { if (fiber->cont.self) { - fiber->cont.self = rb_gc_location(fiber->cont.self); + rb_gc_update_moved(&fiber->cont.self); } else { rb_execution_context_update(&fiber->cont.saved_ec); @@ -1237,7 +1237,7 @@ static void fiber_compact(void *ptr) { rb_fiber_t *fiber = ptr; - fiber->first_proc = rb_gc_location(fiber->first_proc); + rb_gc_update_moved(&fiber->first_proc); if (fiber->prev) rb_fiber_update_self(fiber->prev); diff --git a/gc.c b/gc.c index b12346fe38d795..6fd9c2ca4345bc 100644 --- a/gc.c +++ b/gc.c @@ -3152,6 +3152,15 @@ rb_gc_location(VALUE value) return gc_location_internal(rb_gc_get_objspace(), value); } +void +rb_gc_update_moved(VALUE *ptr) +{ + VALUE destination = rb_gc_location(*ptr); + if (destination != *ptr) { + *ptr = destination; + } +} + #if defined(__wasm__) @@ -4466,9 +4475,7 @@ rb_gc_update_set_refs_i(st_data_t key, st_data_t value, st_data_t argp, int erro static int rb_gc_update_set_refs_replace_i(st_data_t *key, st_data_t *value, st_data_t argp, int existing) { - if (rb_gc_location((VALUE)*key) != (VALUE)*key) { - *key = rb_gc_location((VALUE)*key); - } + rb_gc_update_moved((VALUE *)key); return ST_CONTINUE; } diff --git a/gc/default/default.c b/gc/default/default.c index a6afd9e65c97e9..41417f13f377b2 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -9936,7 +9936,7 @@ gc_update_references_weak_table_i(VALUE obj, void *data) static int gc_update_references_weak_table_replace_i(VALUE *obj, void *data) { - *obj = rb_gc_location(*obj); + rb_gc_update_moved(obj); return ST_CONTINUE; } diff --git a/gc/gc.h b/gc/gc.h index 80a9ffd92f6119..d77ab4ab1296ce 100644 --- a/gc/gc.h +++ b/gc/gc.h @@ -171,7 +171,7 @@ hash_foreach_replace_value(st_data_t key, st_data_t value, st_data_t argp, int e static int hash_replace_ref_value(st_data_t *key, st_data_t *value, st_data_t argp, int existing) { - *value = rb_gc_location((VALUE)*value); + rb_gc_update_moved((VALUE *)value); return ST_CONTINUE; } @@ -219,15 +219,8 @@ hash_foreach_replace(st_data_t key, st_data_t value, st_data_t argp, int error) static int hash_replace_ref(st_data_t *key, st_data_t *value, st_data_t argp, int existing) { - VALUE new_key = rb_gc_location((VALUE)*key); - if (new_key != (VALUE)*key) { - *key = new_key; - } - - VALUE new_value = rb_gc_location((VALUE)*value); - if (new_value != (VALUE)*value) { - *value = new_value; - } + rb_gc_update_moved((VALUE *)key); + rb_gc_update_moved((VALUE *)value); return ST_CONTINUE; } diff --git a/id_table.c b/id_table.c index 7a780bb9e0bcf5..1b0dc756d790ac 100644 --- a/id_table.c +++ b/id_table.c @@ -515,7 +515,7 @@ marked_id_table_compact_check_i(VALUE value, void *data) static enum rb_id_table_iterator_result marked_id_table_compact_replace_i(VALUE *value, void *data, int existing) { - *value = rb_gc_location(*value); + rb_gc_update_moved(value); return ID_TABLE_CONTINUE; } diff --git a/imemo.c b/imemo.c index 8ef12feb7e8de8..bd804fcad64f27 100644 --- a/imemo.c +++ b/imemo.c @@ -422,9 +422,8 @@ rb_imemo_mark_and_move(VALUE obj, bool reference_updating) */ } else if (reference_updating) { - *((VALUE *)&cc->klass) = rb_gc_location(cc->klass); - *((struct rb_callable_method_entry_struct **)&cc->cme_) = - (struct rb_callable_method_entry_struct *)rb_gc_location((VALUE)cc->cme_); + rb_gc_update_moved((VALUE *)&cc->klass); + rb_gc_update_moved_ptr((struct rb_callable_method_entry_struct **)&cc->cme_); RUBY_ASSERT(RB_TYPE_P(cc->klass, T_CLASS) || RB_TYPE_P(cc->klass, T_ICLASS)); RUBY_ASSERT(IMEMO_TYPE_P((VALUE)cc->cme_, imemo_ment)); @@ -488,7 +487,7 @@ rb_imemo_mark_and_move(VALUE obj, bool reference_updating) } if (reference_updating) { - ((VALUE *)env->ep)[VM_ENV_DATA_INDEX_ENV] = rb_gc_location(env->ep[VM_ENV_DATA_INDEX_ENV]); + rb_gc_update_moved(&((VALUE *)env->ep)[VM_ENV_DATA_INDEX_ENV]); } else { if (!VM_ENV_FLAGS(env->ep, VM_ENV_FLAG_WB_REQUIRED)) { @@ -564,7 +563,7 @@ rb_imemo_mark_and_move(VALUE obj, bool reference_updating) VALUE *entries = rb_imemo_subclasses_entries(obj); for (uint32_t i = 0; i < subs->count; i++) { if (entries[i]) { - entries[i] = rb_gc_location(entries[i]); + rb_gc_update_moved(&entries[i]); } } } diff --git a/include/ruby/internal/core/rtypeddata.h b/include/ruby/internal/core/rtypeddata.h index 6b083321cbb47e..90759cfe66402b 100644 --- a/include/ruby/internal/core/rtypeddata.h +++ b/include/ruby/internal/core/rtypeddata.h @@ -282,7 +282,7 @@ struct rb_data_type_struct { * ::rb_data_type_struct::dmark, you need to update references to Ruby * objects inside of your structs. * - * @see rb_gc_location() + * @see rb_gc_update_moved(), rb_gc_location() * @warning This is called during GC runs. Object allocations are * impossible at that moment (that is why GC runs). */ diff --git a/include/ruby/internal/gc.h b/include/ruby/internal/gc.h index 1900b48a75ef9c..8986efd0e90e8d 100644 --- a/include/ruby/internal/gc.h +++ b/include/ruby/internal/gc.h @@ -187,7 +187,8 @@ void rb_gc_mark(VALUE obj); * your struct in the first place. But if that is not possible, use this * function from your ::rb_data_type_struct::dmark then. This way objects * marked using it are considered movable. If you chose this way you have to - * manually fix up locations of such moved pointers using rb_gc_location(). + * manually fix up locations of such moved pointers using rb_gc_update_moved() + * or rb_gc_location(). * * @see Bartlett, Joel F., "Compacting Garbage Collection with Ambiguous * Roots", ACM SIGPLAN Lisp Pointers Volume 1 Issue 6 pp. 3-12, @@ -209,6 +210,18 @@ void rb_gc_mark_movable(VALUE obj); */ VALUE rb_gc_location(VALUE obj); +/** + * Updates a reference to a possibly moved object. This is the same as doing + * `struct->field = rb_gc_location(struct->field)`, except it does not write to + * `struct->field` when the object did not move. Prefer this over the + * assignment, which dirties the page it writes to, and so un-shares it from a + * forked parent process. + * + * @param[in,out] ptr A place holding an object, possibly already moved. + * @post `*ptr` holds the current location of the object. + */ +void rb_gc_update_moved(VALUE *ptr); + /** * Triggers a GC process. This was the only GC entry point that we had at the * beginning. Over time our GC evolved. Now what this function does is just a diff --git a/internal/gc.h b/internal/gc.h index 55b2c965b97db5..cfc04e42da1804 100644 --- a/internal/gc.h +++ b/internal/gc.h @@ -230,8 +230,9 @@ void rb_gc_after_fork(rb_pid_t pid); if (_obj != (VALUE)*(ptr)) *(ptr) = (void *)_obj; \ } while (0) -#define rb_gc_move_ptr(ptr) do { \ - VALUE _obj = rb_gc_location((VALUE)*(ptr)); \ +#define rb_gc_update_moved_ptr(ptr) do { \ + VALUE _obj = (VALUE)*(ptr); \ + rb_gc_update_moved(&_obj); \ if (_obj != (VALUE)*(ptr)) *(ptr) = (void *)_obj; \ } while (0) diff --git a/io_buffer.c b/io_buffer.c index e05d4119251103..4acf76ac1fa266 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -304,7 +304,7 @@ rb_io_buffer_type_compact(void *_buffer) // The `source` String has to be pinned, because the `base` may point to the embedded String content, // which can be otherwise moved by GC compaction. } else { - buffer->source = rb_gc_location(buffer->source); + rb_gc_update_moved(&buffer->source); } } } diff --git a/iseq.c b/iseq.c index 885e1f2aa2a923..f81e244afeefe9 100644 --- a/iseq.c +++ b/iseq.c @@ -436,7 +436,7 @@ rb_iseq_mark_and_move(rb_iseq_t *iseq, bool reference_updating) } if (reference_updating) { - rb_gc_move_ptr(&cds[i].cc); + rb_gc_update_moved_ptr(&cds[i].cc); } else { if (cc_is_active(cc)) { diff --git a/symbol.c b/symbol.c index 32276492d215e0..a6b5741b45e004 100644 --- a/symbol.c +++ b/symbol.c @@ -197,7 +197,7 @@ sym_id_entry_list_compact(void *ptr) struct sym_id_entry *entry; rb_darray_foreach(ary, i, entry) { - entry->str = rb_gc_location(entry->str); + rb_gc_update_moved(&entry->str); } } @@ -251,7 +251,7 @@ id_entry_dir_compact(void *ptr) struct id_entry_dir *dir = ptr; for (long i = 0; i < dir->capa; i++) { if (dir->entries[i]) { - dir->entries[i] = rb_gc_location(dir->entries[i]); + rb_gc_update_moved(&dir->entries[i]); } } } diff --git a/thread_sync.c b/thread_sync.c index 07a578bf56b764..ca8a2217bab2e7 100644 --- a/thread_sync.c +++ b/thread_sync.c @@ -1274,7 +1274,7 @@ static void monitor_compact(void *ptr) { struct rb_monitor *mc = ptr; - mc->mutex = rb_gc_location(mc->mutex); + rb_gc_update_moved(&mc->mutex); } static const rb_data_type_t monitor_data_type = { diff --git a/variable.c b/variable.c index b703819e2affdb..2ff49546c8b2d6 100644 --- a/variable.c +++ b/variable.c @@ -689,13 +689,8 @@ rb_gvar_val_compactor(void *_var) { struct rb_global_variable *var = (struct rb_global_variable *)_var; - VALUE obj = (VALUE)var->data; - - if (obj) { - VALUE new = rb_gc_location(obj); - if (new != obj) { - var->data = (void*)new; - } + if (var->data) { + rb_gc_update_moved_ptr(&var->data); } } diff --git a/vm.c b/vm.c index c9d8ce752a86d8..d1413549a8a7e9 100644 --- a/vm.c +++ b/vm.c @@ -3399,9 +3399,9 @@ rb_vm_update_references(void *ptr) if (ptr) { rb_vm_t *vm = ptr; - vm->self = rb_gc_location(vm->self); - vm->orig_progname = rb_gc_location(vm->orig_progname); - vm->cc_refinement_set = rb_gc_location(vm->cc_refinement_set); + rb_gc_update_moved(&vm->self); + rb_gc_update_moved(&vm->orig_progname); + rb_gc_update_moved(&vm->cc_refinement_set); if (vm->root_box) rb_box_gc_update_references(vm->root_box); @@ -3411,9 +3411,9 @@ rb_vm_update_references(void *ptr) rb_gc_update_values(RUBY_NSIG, vm->trap_list.cmd); if (vm->coverages) { - vm->coverages = rb_gc_location(vm->coverages); - vm->cme2counter = rb_gc_location(vm->cme2counter); - vm->me_set = rb_gc_location(vm->me_set); + rb_gc_update_moved(&vm->coverages); + rb_gc_update_moved(&vm->cme2counter); + rb_gc_update_moved(&vm->me_set); } } } @@ -3785,17 +3785,13 @@ rb_execution_context_update(rb_execution_context_t *ec) // safely use rb_gc_location on such slots. if (!rb_zjit_enabled_p) { for (i = 0; i < (long)(sp - p); i++) { - VALUE ref = p[i]; - VALUE update = rb_gc_location(ref); - if (ref != update) { - p[i] = update; - } + rb_gc_update_moved(&p[i]); } } while (cfp != limit_cfp) { const VALUE *ep = cfp->ep; - cfp->self = rb_gc_location(cfp->self); + rb_gc_update_moved(&cfp->self); if (CFP_ZJIT_FRAME_P(cfp)) { const zjit_jit_frame_t *jit_frame = CFP_ZJIT_FRAME(cfp); rb_zjit_jit_frame_update_references((zjit_jit_frame_t *)jit_frame); @@ -3804,23 +3800,23 @@ rb_execution_context_update(rb_execution_context_t *ec) // was initialized by ZJIT and may have been written later by // vm_caller_setup_arg_block (ISEQ frames) or rb_iterate0 (C frames). if (!jit_frame->materialize_block_code) { - cfp->block_code = (void *)rb_gc_location((VALUE)cfp->block_code); + rb_gc_update_moved_ptr(&cfp->block_code); } } else { - cfp->_iseq = (rb_iseq_t *)rb_gc_location((VALUE)cfp->_iseq); - cfp->block_code = (void *)rb_gc_location((VALUE)cfp->block_code); + rb_gc_update_moved_ptr(&cfp->_iseq); + rb_gc_update_moved_ptr(&cfp->block_code); } if (!VM_ENV_LOCAL_P(ep)) { const VALUE *prev_ep = VM_ENV_PREV_EP(ep); if (VM_ENV_FLAGS(prev_ep, VM_ENV_FLAG_ESCAPED)) { - VM_FORCE_WRITE(&prev_ep[VM_ENV_DATA_INDEX_ENV], rb_gc_location(prev_ep[VM_ENV_DATA_INDEX_ENV])); + rb_gc_update_moved((VALUE *)&prev_ep[VM_ENV_DATA_INDEX_ENV]); } if (VM_ENV_FLAGS(ep, VM_ENV_FLAG_ESCAPED)) { - VM_FORCE_WRITE(&ep[VM_ENV_DATA_INDEX_ENV], rb_gc_location(ep[VM_ENV_DATA_INDEX_ENV])); - VM_FORCE_WRITE(&ep[VM_ENV_DATA_INDEX_ME_CREF], rb_gc_location(ep[VM_ENV_DATA_INDEX_ME_CREF])); + rb_gc_update_moved((VALUE *)&ep[VM_ENV_DATA_INDEX_ENV]); + rb_gc_update_moved((VALUE *)&ep[VM_ENV_DATA_INDEX_ME_CREF]); } } @@ -3828,10 +3824,10 @@ rb_execution_context_update(rb_execution_context_t *ec) } } - ec->storage = rb_gc_location(ec->storage); + rb_gc_update_moved(&ec->storage); - ec->gen_fields_cache.obj = rb_gc_location(ec->gen_fields_cache.obj); - ec->gen_fields_cache.fields_obj = rb_gc_location(ec->gen_fields_cache.fields_obj); + rb_gc_update_moved(&ec->gen_fields_cache.obj); + rb_gc_update_moved(&ec->gen_fields_cache.fields_obj); } static enum rb_id_table_iterator_result @@ -3937,7 +3933,7 @@ thread_compact(void *ptr) { rb_thread_t *th = ptr; - th->self = rb_gc_location(th->self); + rb_gc_update_moved(&th->self); } /* Mark the heap objects a thread owns (the caller handles ec and fiber). Split diff --git a/vm_backtrace.c b/vm_backtrace.c index 19bcd7dc72a0cc..4984f8b08db85b 100644 --- a/vm_backtrace.c +++ b/vm_backtrace.c @@ -143,7 +143,7 @@ static void location_ref_update(void *ptr) { struct valued_frame_info *vfi = ptr; - vfi->btobj = rb_gc_location(vfi->btobj); + rb_gc_update_moved(&vfi->btobj); } static void @@ -798,9 +798,9 @@ backtrace_mark(void *ptr) static void location_update_entry(rb_backtrace_location_t *fi) { - fi->cme = (rb_callable_method_entry_t *)rb_gc_location((VALUE)fi->cme); + rb_gc_update_moved_ptr(&fi->cme); if (fi->iseq) { - fi->iseq = (rb_iseq_t *)rb_gc_location((VALUE)fi->iseq); + rb_gc_update_moved_ptr(&fi->iseq); } } @@ -813,8 +813,8 @@ backtrace_update(void *ptr) for (i=0; ibacktrace[i]); } - bt->strary = rb_gc_location(bt->strary); - bt->locary = rb_gc_location(bt->locary); + rb_gc_update_moved(&bt->strary); + rb_gc_update_moved(&bt->locary); } static const rb_data_type_t backtrace_data_type = { diff --git a/vm_method.c b/vm_method.c index 13ec548976e0cb..e0dc349e19c235 100644 --- a/vm_method.c +++ b/vm_method.c @@ -105,11 +105,11 @@ compact_cc_entry_i(VALUE ccs_ptr, void *data) { struct rb_class_cc_entries *ccs = (struct rb_class_cc_entries *)ccs_ptr; - ccs->cme = (const struct rb_callable_method_entry_struct *)rb_gc_location((VALUE)ccs->cme); + rb_gc_update_moved_ptr(&ccs->cme); VM_ASSERT(vm_ccs_p(ccs)); for (int i=0; ilen; i++) { - ccs->entries[i].cc = (const struct rb_callcache *)rb_gc_location((VALUE)ccs->entries[i].cc); + rb_gc_update_moved_ptr(&ccs->entries[i].cc); } return ID_TABLE_CONTINUE; @@ -776,7 +776,7 @@ cc_refinement_set_compact(void *ptr) { struct cc_refinement_entries *e = ptr; for (size_t i = 0; i < e->len; i++) { - e->entries[i] = rb_gc_location(e->entries[i]); + rb_gc_update_moved(&e->entries[i]); } } diff --git a/weakmap.c b/weakmap.c index e67eb0146d4ef1..3d91a1e6112a96 100644 --- a/weakmap.c +++ b/weakmap.c @@ -91,7 +91,7 @@ wmap_compact_table_replace_i(st_data_t *k, st_data_t *v, st_data_t d, int existi { RUBY_ASSERT((VALUE)*k == rb_gc_location((VALUE)*k)); - *v = (st_data_t)rb_gc_location((VALUE)*v); + rb_gc_update_moved((VALUE *)v); return ST_CONTINUE; } @@ -579,8 +579,8 @@ wkmap_compact_table_replace(st_data_t *key_ptr, st_data_t *val_ptr, st_data_t _d { RUBY_ASSERT(existing); - *key_ptr = (st_data_t)rb_gc_location((VALUE)*key_ptr); - *val_ptr = (st_data_t)rb_gc_location((VALUE)*val_ptr); + rb_gc_update_moved((VALUE *)key_ptr); + rb_gc_update_moved((VALUE *)val_ptr); return ST_CONTINUE; } From da9650803d27cdf4c10710fc335cfdca0ea8c273 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:09:44 +0000 Subject: [PATCH 19/36] Bump taiki-e/install-action Bumps the github-actions group with 1 update in the / directory: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.87.6 to 2.87.7 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c...84f5ac3124727fb3d284d4d22ee9ab3654fd09a6) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/zjit-macos.yml | 2 +- .github/workflows/zjit-ubuntu.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zjit-macos.yml b/.github/workflows/zjit-macos.yml index 648c642bd7ccbb..246d0c8722a0c1 100644 --- a/.github/workflows/zjit-macos.yml +++ b/.github/workflows/zjit-macos.yml @@ -98,7 +98,7 @@ jobs: rustup install ${{ matrix.rust_version }} --profile minimal rustup default ${{ matrix.rust_version }} - - uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 + - uses: taiki-e/install-action@84f5ac3124727fb3d284d4d22ee9ab3654fd09a6 # v2.87.7 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/.github/workflows/zjit-ubuntu.yml b/.github/workflows/zjit-ubuntu.yml index 15427f8b8a5130..9cb401f8a7f2ea 100644 --- a/.github/workflows/zjit-ubuntu.yml +++ b/.github/workflows/zjit-ubuntu.yml @@ -152,7 +152,7 @@ jobs: ruby-version: '3.1' bundler: none - - uses: taiki-e/install-action@7b8d4719ee4aaa279bdf55df38dacb9ebfe12a6c # v2.87.6 + - uses: taiki-e/install-action@84f5ac3124727fb3d284d4d22ee9ab3654fd09a6 # v2.87.7 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} From df3acf49fc203a5dadc0d8a21c56eeb148986f94 Mon Sep 17 00:00:00 2001 From: carlosdanielpohlod Date: Mon, 7 Sep 2026 17:41:48 -0300 Subject: [PATCH 20/36] [DOC] Pathname#rmtree returns self, not 0 The call-seq and the prose both claimed the method returns 0, but it returns self, and has since [Feature #17294] made mkpath and rmtree chainable. test_rmtree asserts it. Also document the keyword arguments, which the call-seq omitted. Fixes [Bug #22288] --- lib/pathname.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pathname.rb b/lib/pathname.rb index c323dfc7e32c7c..1fe1b022d4db99 100644 --- a/lib/pathname.rb +++ b/lib/pathname.rb @@ -88,9 +88,9 @@ class Pathname # * FileUtils * # :markup: markdown # # call-seq: - # rmtree -> 0 + # rmtree(noop: nil, verbose: nil, secure: nil) -> self # - # Deletes the entire filetree at the path in `self`; returns `0`: + # Deletes the entire filetree at the path in `self`; returns `self`: # # ```ruby # dir_pn = Pathname('foo/bar/baz') # => # @@ -103,6 +103,8 @@ class Pathname # * FileUtils * # # Use method #rmdir to delete a single (empty) directory. # + # See FileUtils.rm_rf for keyword arguments. + # def rmtree(noop: nil, verbose: nil, secure: nil) # The name "rmtree" is borrowed from File::Path of Perl. # File::Path provides "mkpath" and "rmtree". From b74498e3a31d5eedb78b9a9906ffaaf236ddad73 Mon Sep 17 00:00:00 2001 From: ydah Date: Wed, 9 Sep 2026 00:18:52 +0900 Subject: [PATCH 21/36] Fix GC crash during Hash#merge Co-authored-by: Luke Gruber --- st.c | 7 +++++++ test/ruby/test_hash.rb | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/st.c b/st.c index 3fa125bbf72658..334938f3b82e16 100644 --- a/st.c +++ b/st.c @@ -596,6 +596,13 @@ st_init_existing_table_with_size(st_table *tab, const struct st_hash_type *type, tab->bin_power = features[n].bin_power; tab->size_ind = features[n].size_ind; + /* The table may be embedded in an object the GC can already reach (T_HASH, + imemo_cdhash), in which case the allocation below can mark it. Empty it + first, marking walks entries[entries_start..entries_bound). */ + tab->entries = NULL; + tab->num_entries = 0; + tab->entries_start = tab->entries_bound = 0; + size_t memsize = get_allocated_entries(tab) * sizeof(st_table_entry); if (tab->entry_power > MAX_POWER2_FOR_TABLES_WITHOUT_BINS) { memsize += bins_size(tab); diff --git a/test/ruby/test_hash.rb b/test/ruby/test_hash.rb index 109b6334bee8c7..8da9d010ec9418 100644 --- a/test/ruby/test_hash.rb +++ b/test/ruby/test_hash.rb @@ -1327,6 +1327,11 @@ def test_merge assert_equal({1=>8, 2=>4, 3=>4, 5=>7}, h1.merge(h2, h3) {|k, v1, v2| k + v1 + v2 }) end + def test_merge_during_gc + hash = @cls[a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8] + assert_equal(9, EnvUtil.under_gc_stress(0x04) { hash.merge(i: 9) }[:i]) + end + def test_merge_on_identhash h = @cls[1=>2,3=>4,5=>6] h.compare_by_identity From e80a4e8846c1b1d638c86cfdd7faea596c159f38 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 8 Sep 2026 20:56:30 +0900 Subject: [PATCH 22/36] mswin: Add statically linked extension exports to the import library The def file is generated from $(LIBRUBY_A) alone, so a symbol that a statically linked extension exports with RUBY_FUNC_EXPORTED reaches the DLL through its own linker directive but never reaches the import library, and an extension built later against that ruby cannot link it. Co-Authored-By: Claude Opus 5 --- win32/Makefile.sub | 4 ++-- win32/mkexports.rb | 30 ++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/win32/Makefile.sub b/win32/Makefile.sub index dd303ed9047014..ccc3e918f509e5 100644 --- a/win32/Makefile.sub +++ b/win32/Makefile.sub @@ -1201,10 +1201,10 @@ $(LIBRUBY_SO): $(LIBRUBY_A) $(DLDOBJS) $(RUBYDEF) $(RUBY_SO_NAME).res $(LIBRUBY_DLDFLAGS) @$(RM) dummy.lib dummy.exp -$(RUBYDEF): $(LIBRUBY_A) $(RBCONFIG) +$(RUBYDEF): $(LIBRUBY_A) $(DLDOBJS) $(RBCONFIG) $(ECHO) generating $(@:\=/) $(Q) $(BOOTSTRAPRUBY_COMMAND) $(srcdir)/win32/mkexports.rb \ - -output=$@ -arch=$(ARCH) $(LIBRUBY_A) + -output=$@ -arch=$(ARCH) $(LIBRUBY_A) -- $(DLDOBJS) {$(win_srcdir)}.def.lib: $(Q) $(AR) $(ARFLAGS)$@ -def:$< diff --git a/win32/mkexports.rb b/win32/mkexports.rb index dc628314b94d37..a51ac29c6243aa 100755 --- a/win32/mkexports.rb +++ b/win32/mkexports.rb @@ -20,8 +20,14 @@ def self.create(*args, &block) klass.new(*args, &block) end + # Files after "--" contribute only the symbols they ask the linker to + # export, not everything they define. def self.extract(objs, *rest) - create(objs).exports(*rest) + dllexports = [] + if i = objs.index("--") + objs, dllexports = objs[0, i], objs[(i + 1)..-1] + end + create(objs, dllexports).exports(*rest) end def self.output(output = $output, &block) @@ -32,7 +38,7 @@ def self.output(output = $output, &block) end end - def initialize(objs) + def initialize(objs, dllexports = []) syms = {} winapis = {} syms["ruby_sysinit_real"] = "ruby_sysinit" @@ -40,6 +46,9 @@ def initialize(objs) syms[internal] = export winapis[$1] = internal if /^_?(rb_w32_\w+)(?:@\d+)?$/ =~ internal end + each_dllexport(dllexports) do |internal, export| + syms[internal] = export + end incdir = File.join(File.dirname(File.dirname(__FILE__)), "include/ruby") read_substitution(incdir+"/win32.h", syms, winapis) read_substitution(incdir+"/subst.h", syms, winapis) @@ -81,6 +90,9 @@ def forwarding(internal, export) def each_export(objs) end + def each_dllexport(objs) + end + def objdump(objs, &block) if objs.empty? $stdin.each_line(&block) @@ -134,6 +146,20 @@ def each_export(objs) yield "strcasecmp", "msvcrt.stricmp" yield "strncasecmp", "msvcrt.strnicmp" end + + def each_dllexport(objs) + return if objs.empty? + objs = objs.collect {|s| s.tr('/', '\\')} + IO.popen(%w"dumpbin -directives" + objs) do |f| + f.each do |l| + next unless /^\s*\/EXPORT:(\S+)/ =~ l + name, kind = $1.split(',', 2) + next if /^_?#{PrivateNames}/o =~ name + name.sub!(/^[@_]/, '') if /@\d+$/ !~ name + yield name, kind == "DATA" + end + end + end end class Exports::Cygwin < Exports From 2cbb37bae7e216d08c40628cbc96246147f1fe5e Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Wed, 9 Sep 2026 02:36:50 +0000 Subject: [PATCH 23/36] [DOC] Update ObjectSpace.each_object for per-Ractor objspaces The note added in a51b4a86fc says this method does not yield Ractor-unshareable objects in multi-Ractor mode, and points at [Bug #19387] as an open implementation issue. That has not been true since per-Ractor GC landed: each Ractor walks its own objspace, so it yields all of its own objects, plus the objects of the other Ractors that have been made shareable. [Bug #19387] Co-Authored-By: Claude Opus 5 (1M context) --- gc.c | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/gc.c b/gc.c index 6fd9c2ca4345bc..71c5c163f0b0de 100644 --- a/gc.c +++ b/gc.c @@ -2097,17 +2097,14 @@ os_obj_of(VALUE of) * Because every live object is visited, this method is mainly useful for * debugging, profiling, and introspecting a running process. * - * Due to a current Ractor implementation issue, this method does not yield - * Ractor-unshareable objects when the process is in multi-Ractor mode. - * Multi-Ractor mode is enabled when Ractor.new has been called for the first - * time. See https://bugs.ruby-lang.org/issues/19387 for more information. + * In multi-Ractor mode this method yields every object of the current Ractor, plus + * the objects of the other Ractors that have been made Ractor-shareable. Another + * Ractor's unshareable objects are never yielded: they belong to that Ractor and the + * current one must not touch them. * - * a = 12345678987654321 # shareable - * b = [].freeze # shareable - * c = {} # not shareable - * ObjectSpace.each_object {|x| x } # yields a, b, and c - * Ractor.new {} # enter multi-Ractor mode - * ObjectSpace.each_object {|x| x } # does not yield c + * c = {} # not shareable, belongs to the main Ractor + * r = Ractor.new { d = {}; receive } # d belongs to r + * ObjectSpace.each_object {|x| x } # yields c, but not d * */ From 11a54ae0f9e1ac7d64fbf64c411e00b37e4dbe49 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Wed, 9 Sep 2026 02:35:59 +0000 Subject: [PATCH 24/36] Keep FL_FINALIZE on the shell an object leaves behind when it is moved move_neutralize_source() rewrites the source's flags to T_OBJECT | FL_FREEZE | (flags & FL_PROMOTED), which drops FL_FINALIZE while the finalizer table entry keyed on that slot stays. The two then disagree, and a RUBY_DEBUG build aborts at shutdown: gc/default/default.c:4044: Assertion Failed: rb_gc_impl_shutdown_call_finalizer_i:RB_FL_TEST(obj, FL_FINALIZE) r = Ractor.new { Ractor.receive } o = Object.new ObjectSpace.define_finalizer(o, proc { |id| }) r.send(o, move: true) Carry FL_FINALIZE over to the shell. The finalizer then runs when the shell is collected, in the Ractor that defined it; the object rebuilt on the other side gets fresh flags and does not inherit it, so it still runs exactly once. [Bug #21368] Co-Authored-By: Claude Opus 5 (1M context) --- ractor.c | 6 +++++- test/ruby/test_ractor.rb | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ractor.c b/ractor.c index da6fc91ed50130..3e610ccaaaa97d 100644 --- a/ractor.c +++ b/ractor.c @@ -2563,7 +2563,11 @@ move_neutralize_source(VALUE obj) break; } - VALUE flags = T_OBJECT | FL_FREEZE | (RBASIC(obj)->flags & FL_PROMOTED); + /* Keep FL_FINALIZE: the finalizer table entry stays keyed on this slot, and a + * shell without the flag makes the two disagree (rb_gc_impl_shutdown_call_finalizer_i + * asserts on it). The finalizer runs when the shell dies, in the Ractor that + * defined it; the rebuilt object gets fresh flags and does not inherit it. */ + VALUE flags = T_OBJECT | FL_FREEZE | (RBASIC(obj)->flags & (FL_PROMOTED | FL_FINALIZE)); /* Read the slot size before the header is rewritten. */ size_t slot_size = rb_gc_obj_slot_size(obj); RBASIC_SET_CLASS_RAW(obj, rb_cRactorMovedObject); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index f0da141472842d..74398486713f67 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -936,4 +936,26 @@ def test_port_queue_dropped_when_port_unreachable RUBY end + + def test_move_object_with_finalizer + # The moved-from shell keeps its finalizer table entry, so it has to keep + # FL_FINALIZE with it; the two disagreeing failed an assertion at shutdown. + assert_normal_exit(<<~'RUBY', '[Bug #21368]') + Warning[:experimental] = false + r = Ractor.new { Ractor.receive } + 1000.times do + o = Object.new + ObjectSpace.define_finalizer(o, proc { |id| }) + r.send(o, move: true) + end + RUBY + + assert_in_out_err(%w[-W0], <<~'RUBY', %w[sent finalized], [], '[Bug #21368]') + r = Ractor.new { Ractor.receive } + o = Object.new + ObjectSpace.define_finalizer(o, proc { |id| $stdout.puts "finalized" }) + r.send(o, move: true) + $stdout.puts "sent" + RUBY + end end From b20a2caf1c738ce5202b9dfca67fe3ac1df2d087 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 8 Sep 2026 15:12:10 +0900 Subject: [PATCH 25/36] Fix memory leak reported in TestRequire#test_loading_fifo_threading_raise The test TestRequire#test_loading_fifo_threading_raise fails with the following error in LSAN because pm_load_parse_file could raise which will cause the pm_parse_result_t to leak. Direct leak of 104 byte(s) in 1 object(s) allocated from: #0 0x64ee1a50385d in calloc build-llvm/tools/clang/stage2-bins/runtimes/runtimes-bins/compiler-rt/lib/asan/asan_malloc_linux.cpp:74:3 #1 0x64ee1a578206 in calloc1 gc/default/default.c:2000:12 #2 0x64ee1a578206 in rb_gc_impl_calloc gc/default/default.c:11069:5 #3 0x64ee1a578206 in ruby_xcalloc_body gc.c:6146:12 #4 0x64ee1a578206 in ruby_xcalloc gc.c:6140:34 #5 0x64ee1acfaed1 in pm_parse_result_init prism_compile.c:10639:23 #6 0x64ee1a67bbd1 in load_iseq_eval load.c:748:13 #7 0x64ee1a674cd4 in rb_load_internal load.c:866:9 #8 0x64ee1a6754d6 in rb_load_entrypoint load.c:908:5 #9 0x64ee1a6790a5 in rb_f_load load.c:950:12 #10 0x64ee1a97043b in vm_call_cfunc_with_frame_ vm_insnhelper.c:3899:11 #11 0x64ee1a957d0b in vm_call_method_each_type vm_insnhelper.c:4891:16 #12 0x64ee1a95779b in vm_call_method vm_insnhelper.c #13 0x64ee1a91a9ad in vm_sendish vm_insnhelper.c:6213:15 #14 0x64ee1a91a9ad in vm_exec_core insns.def:909:11 #15 0x64ee1a9083bd in rb_vm_exec vm.c:2875:22 #16 0x64ee1a54bccc in rb_ec_exec_node eval.c:299:9 #17 0x64ee1a54b993 in ruby_run_node eval.c:337:30 #18 0x64ee1a547f50 in rb_main main.c:42:12 #19 0x64ee1a547f50 in main main.c:62:12 #20 0x7f8507a2a1c9 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 #21 0x7f8507a2a28a in __libc_start_main csu/../csu/libc-start.c:360:3 #22 0x64ee1a45fb74 in _start (ruby+0x197b74) (BuildId: 5b18a734908cec0a5f93267f1f350850b0f3fb6d) Direct leak of 16 byte(s) in 1 object(s) allocated from: #0 0x64ee1a50385d in calloc build-llvm/tools/clang/stage2-bins/runtimes/runtimes-bins/compiler-rt/lib/asan/asan_malloc_linux.cpp:74:3 #1 0x64ee1a578206 in calloc1 gc/default/default.c:2000:12 #2 0x64ee1a578206 in rb_gc_impl_calloc gc/default/default.c:11069:5 #3 0x64ee1a578206 in ruby_xcalloc_body gc.c:6146:12 #4 0x64ee1a578206 in ruby_xcalloc gc.c:6140:34 #5 0x64ee1acfaeb9 in pm_parse_result_init prism_compile.c:10638:21 #6 0x64ee1a67bbd1 in load_iseq_eval load.c:748:13 #7 0x64ee1a674cd4 in rb_load_internal load.c:866:9 #8 0x64ee1a6754d6 in rb_load_entrypoint load.c:908:5 #9 0x64ee1a6790a5 in rb_f_load load.c:950:12 #10 0x64ee1a97043b in vm_call_cfunc_with_frame_ vm_insnhelper.c:3899:11 #11 0x64ee1a957d0b in vm_call_method_each_type vm_insnhelper.c:4891:16 #12 0x64ee1a95779b in vm_call_method vm_insnhelper.c #13 0x64ee1a91a9ad in vm_sendish vm_insnhelper.c:6213:15 #14 0x64ee1a91a9ad in vm_exec_core insns.def:909:11 #15 0x64ee1a9083bd in rb_vm_exec vm.c:2875:22 #16 0x64ee1a54bccc in rb_ec_exec_node eval.c:299:9 #17 0x64ee1a54b993 in ruby_run_node eval.c:337:30 #18 0x64ee1a547f50 in rb_main main.c:42:12 #19 0x64ee1a547f50 in main main.c:62:12 #20 0x7f8507a2a1c9 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16 #21 0x7f8507a2a28a in __libc_start_main csu/../csu/libc-start.c:360:3 #22 0x64ee1a45fb74 in _start (ruby+0x197b74) (BuildId: 5b18a734908cec0a5f93267f1f350850b0f3fb6d) --- load.c | 78 +++++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/load.c b/load.c index 8be85c60047b2f..c6b151abd3cec9 100644 --- a/load.c +++ b/load.c @@ -731,6 +731,46 @@ realpath_internal_cached(VALUE hash, VALUE path) return realpath; } +struct load_prism_args { + pm_parse_result_t result; + VALUE fname; + VALUE realpath_map; + const rb_iseq_t *iseq; + VALUE error; +}; + +static VALUE +load_prism_parse(VALUE args_ptr) +{ + struct load_prism_args *args = (struct load_prism_args *)args_ptr; + pm_parse_result_t *result = &args->result; + VALUE fname = args->fname; + + VALUE error = pm_load_parse_file(result, fname, NULL); + if (error != Qnil) { + args->error = error; + return Qnil; + } + + int error_state; + args->iseq = pm_iseq_new_top(&result->node, rb_fstring_lit(""), fname, + realpath_internal_cached(args->realpath_map, fname), NULL, &error_state); + if (error_state) { + RUBY_ASSERT(args->iseq == NULL); + rb_jump_tag(error_state); + } + + return Qnil; +} + +static VALUE +load_prism_free_result(VALUE args_ptr) +{ + struct load_prism_args *args = (struct load_prism_args *)args_ptr; + pm_parse_result_free(&args->result); + return Qnil; +} + static inline void load_iseq_eval(rb_execution_context_t *ec, VALUE fname) { @@ -744,29 +784,27 @@ load_iseq_eval(rb_execution_context_t *ec, VALUE fname) VALUE realpath_map = box->loaded_features_realpath_map; if (rb_ruby_prism_p()) { - pm_parse_result_t result; - pm_parse_result_init(&result); - result.node.coverage_enabled = 1; - - VALUE error = pm_load_parse_file(&result, fname, NULL); - - if (error == Qnil) { - int error_state; - iseq = pm_iseq_new_top(&result.node, rb_fstring_lit(""), fname, realpath_internal_cached(realpath_map, fname), NULL, &error_state); - - pm_parse_result_free(&result); - - if (error_state) { - RUBY_ASSERT(iseq == NULL); - rb_jump_tag(error_state); - } - } - else { + struct load_prism_args args = { + .fname = fname, + .realpath_map = realpath_map, + .iseq = NULL, + .error = Qnil, + }; + pm_parse_result_init(&args.result); + args.result.node.coverage_enabled = 1; + + /* The parse result must be freed even if parsing or compiling + * raises (e.g. an asynchronously raised IOError while reading a + * pipe), so wrap it in rb_ensure. */ + rb_ensure(load_prism_parse, (VALUE)&args, load_prism_free_result, (VALUE)&args); + + if (args.error != Qnil) { rb_vm_pop_frame(ec); RB_GC_GUARD(v); - pm_parse_result_free(&result); - rb_exc_raise(error); + rb_exc_raise(args.error); } + + iseq = args.iseq; } else { rb_ast_t *ast; From 844ffa9007f7ae67e5f14bbf8ffe7ca190e1d772 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Wed, 9 Sep 2026 02:36:43 +0000 Subject: [PATCH 26/36] Let only the first thread write a bug report When two Ractors fail an assertion at nearly the same time both write into the same stream, and the first report -- the interesting one -- is cut short by the second: rs = 100.times.map { Ractor.new { sleep rand(1..3); Ractor.fail_assert } } produced a 25-line report ending in "Crashed while printing bug report", every time. Take a claim before writing. The first thread through writes its report and aborts the process; a later one waits for that instead of writing its own. The claiming thread is let through again, so the existing crash-while-reporting path still works. The wait is bounded, so a writer that hangs still ends the process as a crash rather than a hang. rb_assert_failure_detail() writes a report without going through report_bug(), so it needs the same claim. The example above now produces one complete report. [Bug #21146] Co-Authored-By: Claude Opus 5 (1M context) --- error.c | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/error.c b/error.c index 923249ebe56fab..b99183e3cccfbc 100644 --- a/error.c +++ b/error.c @@ -1042,9 +1042,41 @@ bug_report_end(FILE *out, rb_pid_t pid) finish_report(out, pid); } +/* Only the first thread to get here writes a report. A second one -- another Ractor + * failing the same assertion, say -- would interleave into it and leave both + * unreadable [Bug #21146], so it waits for the writer to abort the process instead. + * The writer is let through again, for the crash-while-reporting path. */ +static const rb_execution_context_t *bug_reporter_ec; +static rb_atomic_t bug_reporter_claimed; + +static bool +bug_report_claim(void) +{ + const rb_execution_context_t *ec = rb_current_execution_context(false); + + if (RUBY_ATOMIC_CAS(bug_reporter_claimed, 0, 1) == 0) { + bug_reporter_ec = ec; + return true; + } + if (ec != NULL && ec == bug_reporter_ec) { + return true; + } + + /* Bounded, so a writer that hangs ends as a crash and not as a hang. */ + for (int i = 0; i < 100; i++) { +#ifdef _WIN32 + Sleep(100); +#else + struct timespec ts = { 0, 100 * 1000 * 1000 }; + nanosleep(&ts, NULL); +#endif + } + return false; +} + #define report_bug(file, line, fmt, ctx) do { \ rb_pid_t pid = -1; \ - FILE *out = bug_report_file(file, line, &pid); \ + FILE *out = bug_report_claim() ? bug_report_file(file, line, &pid) : NULL; \ if (out) { \ bug_report_begin(out, fmt); \ rb_vm_bugreport(ctx, out); \ @@ -1054,7 +1086,7 @@ bug_report_end(FILE *out, rb_pid_t pid) #define report_bug_valist(file, line, fmt, ctx, args) do { \ rb_pid_t pid = -1; \ - FILE *out = bug_report_file(file, line, &pid); \ + FILE *out = bug_report_claim() ? bug_report_file(file, line, &pid) : NULL; \ if (out) { \ bug_report_begin_valist(out, fmt, args); \ rb_vm_bugreport(ctx, out); \ @@ -1207,7 +1239,7 @@ rb_assert_failure_detail(const char *file, int line, const char *name, const cha const char *fmt, ...) { rb_pid_t pid = -1; - FILE *out = bug_report_file(file, line, &pid); + FILE *out = bug_report_claim() ? bug_report_file(file, line, &pid) : NULL; if (out) { fputs("Assertion Failed: ", out); if (name) fprintf(out, "%s:", name); From 4affd0ba8bc5e3ddb97156549986bd9fd1950cf2 Mon Sep 17 00:00:00 2001 From: Ivo Anjo Date: Wed, 9 Sep 2026 06:14:41 +0100 Subject: [PATCH 27/36] Documentation for modernizing C extensions. (#18492) --- doc/contributing/efficient_extensions.md | 487 +++++++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 doc/contributing/efficient_extensions.md diff --git a/doc/contributing/efficient_extensions.md b/doc/contributing/efficient_extensions.md new file mode 100644 index 00000000000000..78027b5806e6b6 --- /dev/null +++ b/doc/contributing/efficient_extensions.md @@ -0,0 +1,487 @@ +# Tips for updating Ruby C extensions for efficiently using Ruby 4 VM APIs + +## Why? + +The Ruby VM has evolved a lot from Ruby 2 to Ruby 4. While this evolution was reflected in Ruby's C APIs, great care was taken to provide backwards compatibility. Old gems work with little change on Ruby 4, ensuring apps don't get stuck on old versions just because some dependency has not been updated. + +Yet, C extensions that keep using old APIs and design patterns are leaving performance and safety advances "on the table" not only for the extensions themselves, but also silently disabling or hindering major Ruby VM optimizations, getting in the way of GC, Ractors, observability, etc. + +As much as possible, C extensions should have "mechanical sympathy" for the Ruby VM. +When C extensions use old APIs or do things incorrectly, Ruby needs to fall back to safer behaviors, which means the whole application runs more slowly because of those C extensions. + +## The "elephant" in the room + +### Tip: 🟧 _Avoid native extensions if you can_ + +Can you avoid doing the thing in native code at all? See and . Can you use [ffi](https://github.com/ffi/ffi) instead? + +## Arrays + +### Tip: 🟥 _Never use `RARRAY_PTR`_ + +Impact: _Slows down GC forever (while the object lives)_ + +Why: +1. It "wb unprotects" the object forever. Aka object is permanently in young generation, GC always needs to scan it forever even if it's not changing +2. The impact applies even if you only read from the array once; Ruby doesn't know it and is conservative forever +3. You can accidentally write to a frozen array +4. No bounds checking, you can accidentally corrupt memory + +Do instead: +1. Use `rb_ary_entry` to read +2. Use `rb_ary_store` to write +3. If an API requires a contiguous `VALUE` pointer, use `RARRAY_PTR_USE` to keep raw-pointer access scoped + +### Tip: 🟧 _Avoid using `RARRAY_AREF` to read an array_ + +Impact: _No bounds checking (unsafe)_ + +Why: +1. Using `RARRAY_AREF(array, 12345)` will always try to read, even if array is not that long, potentially leading to subtle bugs +2. If you call back into Ruby code while operating on an array, it's possible a different thread will get to run and update the array, so the length may change unexpectedly + +Do instead: +1. Use `rb_ary_entry`, it checks the array is long enough to contain the entry requested + +### Tip: 🟩 _Use `rb_ary_store` to write to an array_ + +Impact: _GC faster, and respects frozen_ + +Why: +1. It checks if an array is frozen before writing to it +2. It does not "write barrier unprotect" the object +3. It correctly implements the (very cheap) write barrier only if needed -- and this only affects the next GC, not all GC forever + +## Hashes + +### Tip: 🟥 _Never use `RHASH_TBL`_ + +Impact: _Slows down GC forever (while the object lives), no bounds/frozen checking_ + +Why: +1. It "wb unprotects" the object forever. Aka object is permanently in young generation, GC always needs to scan it forever even if it's not changing +2. The impact applies even if you only read from the hash once; Ruby doesn't know it and is conservative forever + +(You might recognize the points above as "same downsides as `RARRAY_PTR`", but for hashes) + +3. Forces "ar table" to "st table" conversion => Ruby has a special compact representation for small hashes and this forces the non-compact representation always + +Do instead: +* Use `rb_hash_...` functions + +## Strings + +### Tip: 🟩 _Use fstrings for repeated/long-lived strings_ + +Impact: _Lower app memory use, faster hash lookups, slightly slower creation_ + +Quick detour: What are fstrings? +* TL;DR Global deduplicated frozen strings +* Same as `String#dedup`/`String#-@` but directly from C + +``` +VALUE v1 = rb_interned_str_cstr("hello"); +VALUE v2 = rb_interned_str_cstr("hello"); +``` + +`v1 == v2` in C (same object!) + +(Very similar behavior to symbols!) + +Why: +1. It lowers memory use since only one copy of string data is kept +2. It speeds up hash inserts with string keys, since a non-frozen String key gets fstring-deduplicated on insert anyway -- passing an fstring avoids that extra work + +## Memory + +### Tip: 🟩 _Use `ruby_xmalloc`/`ruby_xfree`/`ruby_x`... to manage memory_ + +Impact: _Improved low memory handling, improved GC behavior, safer_ + +Why: +1. Ruby will automatically GC if the app runs out of memory to try to recover +2. Ruby will trigger GC after some amount of allocations to avoid fragmentation and high memory usage +3. No need to error check -- it always returns memory or raises an exception directly +4. Maybe MMTk will be able to optimize it further in the future? ;) + +(mmtk.c) + +```c +// Malloc +void * +rb_gc_impl_malloc(void *objspace_ptr, size_t size, bool gc_allowed) +{ + // TODO: don't use system malloc + return malloc(size); +} +``` + +### Tip: 🟧 _Be careful about making objects "immortal"_ + +Impact: _Higher memory use_ + +Why: +1. APIs such as `rb_gc_register_mark_object` and `rb_define_class`/`rb_define_module` cause objects to become immortal -- they can never be garbage collected nor can the garbage collector move them during compaction + +Do instead (*sometimes?): +1. `rb_global_variable` prevents an object referenced from being garbage collected and moved, but once the variable stops pointing at the object the effect disappears + +### Tip: 🟩 _Use sized/bulk APIs to avoid unnecessary resizing of Arrays, Hashes, Strings_ + +When creating Arrays, Hashes, and Strings, you can often provide sizes and perform operations in bulk. + +Look for APIs such as: + +* Arrays: `rb_ary_new_capa(size)` / `rb_ary_new_from_args(size, ...)` / `rb_ary_new_from_values(size, ...)` / `rb_ary_cat(...)` +* Hashes: `rb_hash_new_capa` / `rb_hash_bulk_insert` +* Strings: `rb_str_buf_new(capa)` + +Impact: _Faster performance, reduced memory usage_ + +Why: +1. When Ruby knows the size of objects, it can take advantage of Variable Width Allocation to find a size pool that can contain the entire object, keeping all the data together in memory and saving the overhead of extra allocations +2. When performing mutations in bulk, Ruby can resize the object once to fit all the changes, rather than needing to incrementally grow +3. Calling into Ruby once with all the changes is often faster than calling many times to feed each change (as there are checks that need to be done on every call, for instance) + +## Ractors + +### Tip: 🟩 _If your extension is Ractor-safe, remember to tell Ruby about it with `rb_ext_ractor_safe`_ + +Impact: _Faster performance! (Even very slightly with a single Ractor)_ + +Why: +1. Allow Ruby app to take advantage of true parallelism +2. When an extension doesn't declare itself Ractor-safe, Ruby wraps every method call with the equivalent of + +```c +VALUE call_cfunc(...) { + if (!rb_ractor_main_p()) { + rb_raise(rb_eRactorUnsafeError, "ractor unsafe method called from not main ractor"); + } + return call_original_function(...) +} +``` + +Note: `rb_ext_ractor_safe(true)` must be called **before** the `rb_define_method` calls for the methods you want optimized. +It changes the invoker used for methods defined *afterward*; calling it later does not retrofit already-defined methods. +Whenever possible, consider doing it in the extension's `Init_...` function. + +## TypedData + +What is TypedData? It allows a Ruby object to wrap a C struct or similar native memory. + +```c +struct SimpleSomeStruct { + int internal_info; +}; +VALUE obj = TypedData_Make_Struct(klass, struct SimpleSomeStruct, &some_typed_data_type, ptr); // <-- Wraps struct with Ruby object +``` + +But a TypedData object has one key very powerful feature -- it can hold **references** to Ruby objects. That is, you can use it to implement your own custom instance variables, arrays, hashes, etc that reference Ruby objects. + +```c +struct SomeStruct { + int internal_info; + VALUE some_reference; // <-- Here we can keep a reference to another ruby object! Like a C "instance variable" +}; +VALUE obj = ...; +``` + +BUT holding references to Ruby objects means TypedData must coordinate with the Ruby garbage collector. +And the Ruby garbage collector has evolved quite a bit from Ruby 2 to 4 to get the most performance out of your applications, and this evolution often cannot be hidden/abstracted away from TypedData objects. +TypedData + Ruby references is the ultimate in "mechanical sympathy". + +**Running example.** We'll use this `SomeStruct` as the basis for a `SomeClass` that we'll carry through the rest of the section. + +```c +static void some_struct_mark(void *ptr) { + struct SomeStruct *data = ptr; + rb_gc_mark(data->some_reference); // pins the reference, see tip on `dcompact` below +} + +static const rb_data_type_t some_typed_data_type = { + .wrap_struct_name = "SomeStruct", + .function = { + .dmark = some_struct_mark, + .dfree = RUBY_TYPED_DEFAULT_FREE, // = ruby_xfree + // .dsize omitted -- see tip on `dsize` below + // .dcompact omitted -- see tip on `dcompact` below + }, + // .flags omitted -- see tips below +}; + +static VALUE some_class_set_some_reference(VALUE self, VALUE reference) { + struct SomeStruct *data; + TypedData_Get_Struct(self, struct SomeStruct, &some_typed_data_type, data); + data->some_reference = reference; // no write barrier -- see tip on it below + return reference; +} +``` + +### Tip: 🟧 _Avoid using TypedData, especially for referencing Ruby objects if you can!_ + +Impact: _Simplicity and correctness (sanity?)_ + +Why: +1. As we'll see, implementing all of the TypedData features is quite complex +2. If you can store your extension's data in a regular Ruby array or Ruby hash, consider doing that! Those are extremely optimized already! +3. You'll be able to ignore ALL of the tips that follow! + +### Tip: 🟩 _Use declarative marking_ + +Impact: _Lower memory usage, and easier to maintain_ + +Why: +1. GC compaction enables Ruby to move objects together, improving performance and reducing memory usage (including across forks) +2. Declarative marking avoids having separate mark and compact functions that need to be kept in-sync + +Applied to the running example -- delete the hand-written `some_struct_mark`, declare the offsets, flip the flag: + +```c +RUBY_REFERENCES(some_struct_refs) = { + RUBY_REF_EDGE(struct SomeStruct, some_reference), + RUBY_REF_END, +}; + +static const rb_data_type_t some_typed_data_type = { + .wrap_struct_name = "SomeStruct", + .function = { + .dmark = RUBY_REFS_LIST_PTR(some_struct_refs), // refs go in dmark, not data! + .dfree = RUBY_TYPED_DEFAULT_FREE, + }, + .flags = RUBY_TYPED_DECL_MARKING, +}; +``` + +No `dmark` function, no `dcompact` function -- the GC walks the offset list for both marking and compaction. +If you later add a new `VALUE` member to `struct SomeStruct`, remember to add a matching `RUBY_REF_EDGE` and that's it! + +### Tip: 🟥 _Always provide a dcompact function when referencing Ruby objects_ + +(*UNLESS you're using declarative marking) + +Impact: _Pins referenced objects, preventing them from being compacted_ + +Why: +1. A dcompact allows Ruby to still do GC compaction, even when not using declarative marking +2. Without it, objects referenced by a TypedData object are "pinned" in place, unable to ever move + +Applied to the running example -- change `rb_gc_mark` to `rb_gc_mark_movable` and add a compact callback: + +```c +static void some_struct_mark(void *ptr) { + struct SomeStruct *data = ptr; + rb_gc_mark_movable(data->some_reference); // was rb_gc_mark -> pinned +} + +static void some_struct_compact(void *ptr) { + struct SomeStruct *data = ptr; + VALUE ref = rb_gc_location(data->some_reference); + if (ref != data->some_reference) { // Copy-on-write friendly check, don't rewrite unless it changed + data->some_reference = ref; + } +} + +static const rb_data_type_t some_typed_data_type = { + .wrap_struct_name = "SomeStruct", + .function = { + .dmark = some_struct_mark, + .dfree = RUBY_TYPED_DEFAULT_FREE, + .dcompact = some_struct_compact, // NEW + }, +}; +``` + +`rb_gc_location` returns the (possibly new) address of an object that was marked movable. +If the object wasn't moved, it returns the same `VALUE` unchanged -- so it's always safe to assign back. + +### Tip: 🟥 _Do not use TypedData without `RUBY_TYPED_WB_PROTECTED`_ + +Impact: _Slows down GC_ + +Why: +1. It "wb unprotects" the object forever. Aka object is permanently in young generation, GC always needs to scan it forever even if it's not changing + +Do instead: +1. Declare TypedData as `RUBY_TYPED_WB_PROTECTED` +2. Whenever a reference is written, you must use `RB_OBJ_WRITE` -- don't forget! + +Applied to the running example -- set the flag AND route every `VALUE` store through `RB_OBJ_WRITE`: + +```c +static VALUE some_class_set_some_reference(VALUE self, VALUE reference) { + struct SomeStruct *data; + TypedData_Get_Struct(self, struct SomeStruct, &some_typed_data_type, data); + RB_OBJ_WRITE(self, &data->some_reference, reference); // was: data->some_reference = reference; + return reference; +} + +static const rb_data_type_t some_typed_data_type = { + .wrap_struct_name = "SomeStruct", + .function = { ... }, + .flags = RUBY_TYPED_WB_PROTECTED, // NEW +}; +``` + +### Tip: 🟩 _Implement dsize_ + +Impact: _Accurate memory information_ + +Why: +1. This exposes correct memory accounting to `ObjectSpace` APIs (`memsize_of`, `count_objects_size`, `dump`, `dump_all`) +2. Otherwise Ruby will only count the Ruby size of the object, not the C size + +Do: +1. Write a dsize! ;) + +Applied to the running example -- add a size reporter: + +```c +static size_t some_struct_dsize(const void *ptr) { + return sizeof(struct SomeStruct); +} + +static const rb_data_type_t some_typed_data_type = { + .wrap_struct_name = "SomeStruct", + .function = { + .dmark = some_struct_mark, + .dfree = RUBY_TYPED_DEFAULT_FREE, + .dsize = some_struct_dsize, // NEW + }, + ... +}; +``` + +If `struct SomeStruct` later owns heap-allocated memory (e.g. malloc and the like), include their sizes in the return value too. + +### Tip: 🟧 _If possible, use `RUBY_TYPED_FREE_IMMEDIATELY`_ + +Impact: _Faster garbage collection and earlier memory reclamation_ + +Why: +1. When `RUBY_TYPED_FREE_IMMEDIATELY` is set, you are "promising" to Ruby that it's safe to call dfree immediately, during GC. To make your function safe, you should never call into Ruby APIs (like trying to release the GVL) and ideally avoid any kind of blocking or I/O. A function that just calls xfree/free on things is one example of something free to call immediately. With this "promise", Ruby is able to call the dfree immediately during object sweeping, thus freeing up the memory immediately. +2. Without it -- that is, for a custom `dfree` callback that hasn't opted in -- Ruby keeps the object as a "zombie", then needs to add it to a list of pages to finalize, then needs to do extra work to go through the page, etc... Hard to avoid in some situations. This deferred handling applies specifically to custom `dfree` functions; `RUBY_TYPED_DEFAULT_FREE` (used in our example) is already recognized and safely executed immediately during sweeping regardless of this flag, since Ruby knows in advance that it's just a plain `xfree`. + +Applied to the running example -- our `dfree` is just `ruby_xfree` (never touches the GVL, never blocks), so the flag is safe to add: + +```c +static const rb_data_type_t some_typed_data_type = { + ... + .flags = RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_FREE_IMMEDIATELY, // + FREE_IMMEDIATELY +}; +``` + +### Tip: 🟧 _Consider using `RUBY_TYPED_THREAD_SAFE_FREE`_ + +Impact: _Faster GC when multiple Ractors are running_ + +Why: +1. `RUBY_TYPED_THREAD_SAFE_FREE` (new in Ruby 4.1) tells Ruby it's safe to call your `dfree` function from multiple Ractors in parallel, letting Ractor-local GC sweep objects of this type without deferring +2. Without it, once more than one Ractor is running, Ruby doesn't know if it's safe to call `dfree` concurrently, so it defers freeing those objects to a later time +3. Having no `dfree` function, or using `RUBY_TYPED_DEFAULT_FREE` (as in our running example), is already automatically safe to sweep in parallel -- this flag only matters if you have a custom `dfree` + +Do make sure your `dfree` is genuinely thread-safe (doesn't mutate global/shared state) before adding this flag. + +### Tip: 🟧 _Consider using `RUBY_TYPED_EMBEDDABLE`_ + +Impact: _Faster GC, fewer memory allocations, better code performance (trade off with complexity)_ + +Why: +1. By embedding the struct directly inside the Ruby object, we avoid having the classic split of TypedData objects into two parts +2. Which means the second part does not need a separate malloc -> less work AND means the second part does not need a separate free -> less work +3. Which means less pointer chasing by the cpu, and better cache use (reading the object will bring the rest into the cache) +4. Proven track record of improving performance for core types + +But be careful -- see the latest Ruby docs on and the discussion on , especially around the use of `RB_GC_GUARD`, not storing pointers to/into the C structure, how `dfree`/`dsize` need to be changed, and `RUBY_TYPED_FREE_IMMEDIATELY` being required. + +This is kind-of an advanced use case, so I recommend it only as an optimization for objects accessed very often, or created in large numbers. "With great power comes great responsibility" kinda feature. + +As per , this is a Ruby 4.1+ feature "officially", but you can use it as far back as Ruby 3.3... +I think? I didn't find any reason _not_ to use it, do let me know if I missed it. + +Applied to the running example -- `struct SomeStruct` is tiny (one int + one `VALUE`), so it's a strong candidate for embedding directly in the Ruby object slot: + +```c +static size_t some_struct_dsize(const void *ptr) { + return 0; // struct now embedded in the Ruby object slot, so Ruby + // already accounts for its size -- report only auxiliary + // heap allocations here +} + +static const rb_data_type_t some_typed_data_type = { + ... + .function = { + ... + .dsize = some_struct_dsize, // CHANGED: no longer counts sizeof(struct SomeStruct) + }, + .flags = RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_EMBEDDABLE, +}; +``` + +Note that it requires `RUBY_TYPED_FREE_IMMEDIATELY`. + +Also note that `dsize` must be revisited: once the struct is embedded, its memory is already part of the Ruby object slot, so `dsize` returning `sizeof(struct SomeStruct)` (as in the earlier tip) would double-count it in `ObjectSpace` accounting. `dsize` should report only auxiliary heap allocations owned by the struct (e.g. malloc'd buffers) -- zero if there are none, as here. + +### Tip: 🟧 _Consider using mass marking/compacting functions_ + +Ruby provides a number of "mark all/compact all" reference functions: + +* `rb_gc_mark_locations(const VALUE *start, const VALUE *end)` + * Conservatively (maybe) mark + pin everything between two pointers +* `rb_mark_hash(struct st_table *tbl)` + * For an `st_table` with `VALUE` keys and values + * Exact mark + pin every key and value +* `rb_mark_set(struct st_table *tbl)` + * For an `st_table` with `VALUE` keys + * Exact mark + pin every key +* `rb_mark_tbl(struct st_table *tbl)` + * For an `st_table` with `VALUE` values + * Exact mark + pin every value + * (Doesn't do anything with the keys) +* `rb_mark_tbl_no_pin(struct st_table *tbl)` + * For an `st_table` with `VALUE` values + * Exact mark + **does not pin** every value + * (Doesn't do anything with the keys) + * During `dcompact`, you need to update values with `rb_gc_location` + * I advise against using `rb_gc_update_tbl_refs` as the documentation is incorrect -- see + +And there's a few more variants that are available but hidden: +* `rb_gc_mark_values(long n, const VALUE *values)` + * Similar in spirit to `rb_gc_mark_locations` but exact mark + **no pin** + * `rb_gc_update_values(long n, VALUE *values)` should be used in `dcompact` +* `rb_gc_mark_vm_stack_values(long n, const VALUE *values)` + * Similar in spirit to `rb_gc_mark_locations` but exact mark + **pin** + +In practice, there's a combination of: +* Kind/shape of data structure (st_table, begin/end pointers, beginning + length) +* Conservative (maybe) mark vs exact mark +* Pin vs no-pin +* Availability of `dcompact` counterpart + +that aren't available. Would it be useful for gems to support more? + +Impact: _Maybe (?) faster GC, simplification_ + +Why: +1. As with any low-level vs high-level API, it's much better if you can tell Ruby "I want to mark all of this" vs going one by one +2. ...Although Ruby right now maps this to "one-by-one" so... the gain is more on expressiveness than speed (and maybe future MMTk optimization?) + +### Tip: 🟩 _Prefer `TypedData_Make_Struct` over `TypedData_Wrap_Struct`_ + +Impact: _Avoids memory leaks on the error path_ + +Why: +1. `TypedData_Wrap_Struct` takes a struct pointer you allocated yourself. If wrapping raises (e.g. out-of-memory when allocating the Ruby object), the struct leaks. +2. `TypedData_Make_Struct` allocates both the Ruby object and the struct as a single operation -- nothing to clean up on failure. + +Sharp edge: if struct allocation inside `Make_Struct` fails, you can end up with a Ruby object whose data pointer is `NULL`. Still usually better than the leak. Reach for `Wrap_Struct` only when you already have a pre-existing struct pointer you can't recreate. + +### Tip: 🟧 _Set `RUBY_TYPED_FROZEN_SHAREABLE` if you want frozen TypedData to cross Ractors_ + +Impact: _Allows frozen instances to be shared across Ractors without copying_ + +Why: +1. Without the flag, `Ractor.make_shareable(obj)` raises for your type. +2. With the flag (plus freezing + `Ractor.make_shareable`), the object can be read from any Ractor. + +Do make sure that your object and C code are able to correctly run across many Ractors with no thread safety issues. From 7427bf79e4f56c716c6c62eb47d84b1b972e6d53 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 9 Sep 2026 13:08:27 +0900 Subject: [PATCH 28/36] [ruby/rubygems] Bump Bundler version to 4.1.0.beta1 https://github.com/ruby/rubygems/commit/e571e56e44 --- lib/bundler/version.rb | 2 +- spec/bundler/realworld/fixtures/tapioca/Gemfile.lock | 2 +- spec/bundler/realworld/fixtures/warbler/Gemfile.lock | 2 +- tool/bundler/dev_gems.rb.lock | 2 +- tool/bundler/rubocop_gems.rb.lock | 2 +- tool/bundler/standard_gems.rb.lock | 2 +- tool/bundler/test_gems.rb.lock | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/bundler/version.rb b/lib/bundler/version.rb index ca7bb0719aac24..6a1de4efc6baf8 100644 --- a/lib/bundler/version.rb +++ b/lib/bundler/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: false module Bundler - VERSION = "4.1.0.dev".freeze + VERSION = "4.1.0.beta1".freeze def self.bundler_major_version @bundler_major_version ||= gem_version.segments.first diff --git a/spec/bundler/realworld/fixtures/tapioca/Gemfile.lock b/spec/bundler/realworld/fixtures/tapioca/Gemfile.lock index a08089a6f7b33b..9bf8b9585bc173 100644 --- a/spec/bundler/realworld/fixtures/tapioca/Gemfile.lock +++ b/spec/bundler/realworld/fixtures/tapioca/Gemfile.lock @@ -46,4 +46,4 @@ DEPENDENCIES tapioca BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 diff --git a/spec/bundler/realworld/fixtures/warbler/Gemfile.lock b/spec/bundler/realworld/fixtures/warbler/Gemfile.lock index 05f3bc4e3f1d86..9e6c2fe23d7df6 100644 --- a/spec/bundler/realworld/fixtures/warbler/Gemfile.lock +++ b/spec/bundler/realworld/fixtures/warbler/Gemfile.lock @@ -32,4 +32,4 @@ DEPENDENCIES warbler (~> 2.1) BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 diff --git a/tool/bundler/dev_gems.rb.lock b/tool/bundler/dev_gems.rb.lock index 72916fb1e1d95f..0e715aa3c5599d 100644 --- a/tool/bundler/dev_gems.rb.lock +++ b/tool/bundler/dev_gems.rb.lock @@ -132,4 +132,4 @@ CHECKSUMS turbo_tests (2.2.5) sha256=3fa31497d12976d11ccc298add29107b92bda94a90d8a0a5783f06f05102509f BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 diff --git a/tool/bundler/rubocop_gems.rb.lock b/tool/bundler/rubocop_gems.rb.lock index f265e7c9ebd52b..b0b2899f3f8d89 100644 --- a/tool/bundler/rubocop_gems.rb.lock +++ b/tool/bundler/rubocop_gems.rb.lock @@ -157,4 +157,4 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 diff --git a/tool/bundler/standard_gems.rb.lock b/tool/bundler/standard_gems.rb.lock index 8ef7806bcc80e6..9b3ecaa36a9057 100644 --- a/tool/bundler/standard_gems.rb.lock +++ b/tool/bundler/standard_gems.rb.lock @@ -177,4 +177,4 @@ CHECKSUMS unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 diff --git a/tool/bundler/test_gems.rb.lock b/tool/bundler/test_gems.rb.lock index 0b9ac341625e3b..9ca68b3d9883ff 100644 --- a/tool/bundler/test_gems.rb.lock +++ b/tool/bundler/test_gems.rb.lock @@ -99,4 +99,4 @@ CHECKSUMS tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 BUNDLED WITH - 4.1.0.dev + 4.1.0.beta1 From 4020342576a90012174ca4329c75b5937b5aea98 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 9 Sep 2026 13:08:27 +0900 Subject: [PATCH 29/36] [ruby/rubygems] Bump Rubygems version to 4.1.0.beta1 https://github.com/ruby/rubygems/commit/98e4d8083a --- lib/rubygems.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubygems.rb b/lib/rubygems.rb index 0ff3beaea1f83c..85b4055951648f 100644 --- a/lib/rubygems.rb +++ b/lib/rubygems.rb @@ -9,7 +9,7 @@ require "rbconfig" module Gem - VERSION = "4.1.0.dev" + VERSION = "4.1.0.beta1" end require_relative "rubygems/defaults" From 26878027ff2cf51f9d003e8998656bb0159a5376 Mon Sep 17 00:00:00 2001 From: git Date: Wed, 9 Sep 2026 05:15:38 +0000 Subject: [PATCH 30/36] Update default gems list at 4020342576a90012174ca4329c75b5 [ci skip] --- NEWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index f969edbd136ce2..b9358157038422 100644 --- a/NEWS.md +++ b/NEWS.md @@ -177,9 +177,9 @@ They are still available on rubygems.org and can be installed with ### The following default gems are updated. -* RubyGems 4.1.0.dev +* RubyGems 4.1.0.beta1 * 4.0.3 to [v4.0.4][RubyGems-v4.0.4], [v4.0.5][RubyGems-v4.0.5], [v4.0.6][RubyGems-v4.0.6], [v4.0.7][RubyGems-v4.0.7], [v4.0.8][RubyGems-v4.0.8], [v4.0.9][RubyGems-v4.0.9], [v4.0.10][RubyGems-v4.0.10], [v4.0.11][RubyGems-v4.0.11], [v4.0.12][RubyGems-v4.0.12], [v4.0.13][RubyGems-v4.0.13], [v4.0.14][RubyGems-v4.0.14], [v4.0.15][RubyGems-v4.0.15], [v4.0.16][RubyGems-v4.0.16], [v4.0.17][RubyGems-v4.0.17], [v4.0.18][RubyGems-v4.0.18], [v4.0.19][RubyGems-v4.0.19], [v4.0.20][RubyGems-v4.0.20] -* bundler 4.1.0.dev +* bundler 4.1.0.beta1 * 4.0.3 to [v4.0.4][bundler-v4.0.4], [v4.0.5][bundler-v4.0.5], [v4.0.6][bundler-v4.0.6], [v4.0.7][bundler-v4.0.7], [v4.0.8][bundler-v4.0.8], [v4.0.9][bundler-v4.0.9], [v4.0.10][bundler-v4.0.10], [v4.0.11][bundler-v4.0.11], [v4.0.12][bundler-v4.0.12], [v4.0.13][bundler-v4.0.13], [v4.0.14][bundler-v4.0.14], [v4.0.15][bundler-v4.0.15], [v4.0.16][bundler-v4.0.16], [v4.0.17][bundler-v4.0.17] * erb 6.0.7 * 6.0.1 to [v6.0.1.1][erb-v6.0.1.1], [v6.0.2][erb-v6.0.2], [v6.0.3][erb-v6.0.3], [v6.0.4][erb-v6.0.4], [v6.0.5][erb-v6.0.5], [v6.0.6][erb-v6.0.6], [v6.0.7][erb-v6.0.7] From f3a976006b7f5306d07fa580aed687f1b9ff1255 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Wed, 9 Sep 2026 14:20:20 +0900 Subject: [PATCH 31/36] Fix unused warning for mn_threads_enabled_p Fixes the following warning: thread_sched.c:114:1: warning: unused function 'mn_threads_enabled_p' [-Wunused-function] 114 | mn_threads_enabled_p(void) | ^~~~~~~~~~~~~~~~~~~~ --- thread_sched.c | 7 ------- thread_sched_mn.c | 5 +++++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/thread_sched.c b/thread_sched.c index 7bceb1f66cc934..e02d97a9c93900 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -109,13 +109,6 @@ static void timer_thread_wakeup_force(void); // 1 = the main Ractor's threads too; 2 = the main thread as well. static int mn_threads_mode = 0; -// Only consulted from USE_MN_THREADS code; the platform gate is not defined yet here. -static bool -mn_threads_enabled_p(void) -{ - return mn_threads_mode >= 0; -} - static void nt_snts_join(rb_vm_t *vm, struct rb_native_thread *nt); static void nt_snts_leave(rb_vm_t *vm, struct rb_native_thread *nt); static bool nt_shared_loop(struct rb_native_thread *nt); diff --git a/thread_sched_mn.c b/thread_sched_mn.c index 73a195c557f3ae..9f66d0efc37371 100644 --- a/thread_sched_mn.c +++ b/thread_sched_mn.c @@ -919,6 +919,11 @@ nt_free_stack(void *mstack) rb_native_mutex_unlock(&nt_machine_stack_lock); } +static bool +mn_threads_enabled_p(void) +{ + return mn_threads_mode >= 0; +} static int native_thread_check_and_create_shared(rb_vm_t *vm) From 967e607789eb4b90bd293a29487a01b3ddc4f5c7 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Tue, 8 Sep 2026 22:15:04 -0700 Subject: [PATCH 32/36] [ruby/json] Fix the JSON::Coder.new call-seq `JSON` has no `.new`, and options have been keyword arguments since 3.0, so passing a positional Hash raises ArgumentError. https://github.com/ruby/json/commit/effca27964 --- ext/json/lib/json/common.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/json/lib/json/common.rb b/ext/json/lib/json/common.rb index ebec553a25464d..82d334061e29d5 100644 --- a/ext/json/lib/json/common.rb +++ b/ext/json/lib/json/common.rb @@ -812,9 +812,9 @@ class Coder private_constant :EXCLUDED_GENERATOR_OPTIONS # :call-seq: - # JSON.new(options = nil, &block) + # JSON::Coder.new(**options, &block) # - # Argument +options+, if given, contains a \Hash of options for both parsing and generating. + # Keyword arguments +options+, if given, are options for both parsing and generating. # See {Parsing Options}[rdoc-ref:JSON@Parsing+Options], # and {Generating Options}[rdoc-ref:JSON@Generating+Options]. # From 344fa79bc6876160cc03eba4e2aeffa6152c59ff Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 9 Sep 2026 07:22:26 +0100 Subject: [PATCH 33/36] [ruby/json] Release 3.0.2 https://github.com/ruby/json/commit/7b2a23deea --- ext/json/lib/json/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/json/lib/json/version.rb b/ext/json/lib/json/version.rb index 1cf83a03954159..318272defdad67 100644 --- a/ext/json/lib/json/version.rb +++ b/ext/json/lib/json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JSON - VERSION = '3.0.1' + VERSION = '3.0.2' end From 2fb90e53130c822fd140ca4111ea960a612f7ebc Mon Sep 17 00:00:00 2001 From: git Date: Wed, 9 Sep 2026 06:25:06 +0000 Subject: [PATCH 34/36] Update default gems list at 344fa79bc6876160cc03eba4e2aeff [ci skip] --- NEWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index b9358157038422..86ddf609d18d45 100644 --- a/NEWS.md +++ b/NEWS.md @@ -188,7 +188,7 @@ They are still available on rubygems.org and can be installed with * 0.8.2 to [v0.9.0][io-console-v0.9.0], [v0.9.1][io-console-v0.9.1], [v0.9.2][io-console-v0.9.2] * ipaddr 1.2.9 * 1.2.8 to [v1.2.9][ipaddr-v1.2.9] -* json 3.0.1 +* json 3.0.2 * 2.18.0 to [v2.18.1][json-v2.18.1], [v2.19.0][json-v2.19.0], [v2.19.1][json-v2.19.1], [v2.19.2][json-v2.19.2], [v2.19.3][json-v2.19.3], [v2.19.4][json-v2.19.4], [v2.19.5][json-v2.19.5], [v2.19.6][json-v2.19.6], [v2.19.7][json-v2.19.7], [v2.19.8][json-v2.19.8], [v2.19.9][json-v2.19.9], [v2.20.0][json-v2.20.0], [v2.21.0][json-v2.21.0], [v2.21.2][json-v2.21.2], [v3.0.0.rc1][json-v3.0.0.rc1], [v3.0.0][json-v3.0.0] * net-protocol 0.3.0 * 0.2.2 to [v0.3.0][net-protocol-v0.3.0] From b02556a290c888853a86112f1c61177b18d8467b Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 9 Sep 2026 07:24:56 +0100 Subject: [PATCH 35/36] [ruby/erb] Speedup `ERB::Util.html_escape` with SIMD (https://github.com/ruby/erb/pull/136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Speedup ERB::Util.html_escape with SIMD The code is lifted from similar work in the `json` gem. SIMD use is limited to NEON and SSE2 as they can easily be detected and assumed at compile time. Using more advanced SIMD implementations would require runtime detection, which probably isn't worth it. ``` == 1k no matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 1.430M i/100ms Calculating ------------------------------------- simd 14.719M (± 0.5%) i/s (67.94 ns/i) - 74.350M in 5.051379s Comparison: master: 2389369.8 i/s simd: 14718663.6 i/s - 6.16x faster == 1k few matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 163.731k i/100ms Calculating ------------------------------------- simd 1.702M (± 0.6%) i/s (587.67 ns/i) - 8.514M in 5.003388s Comparison: master: 1405436.9 i/s simd: 1701649.4 i/s - 1.21x faster == 1k many matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- simd 153.148k i/100ms Calculating ------------------------------------- simd 1.590M (± 1.2%) i/s (629.01 ns/i) - 7.964M in 5.009249s Comparison: master: 1225987.4 i/s simd: 1589798.4 i/s - 1.30x faster ``` NB: I don't have an x86_64 machine to benchmark SSE2. https://github.com/ruby/erb/commit/d5ddd13686 --- ext/erb/escape/escape.c | 245 +++++++++++++++++++++++++++++++++++--- ext/erb/escape/extconf.rb | 22 ++++ test/erb/test_erb.rb | 2 +- 3 files changed, 249 insertions(+), 20 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index b3184ddc6b7c7f..d4a53d0eb13714 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -29,37 +29,244 @@ escaped_length(VALUE str) return len * HTML_ESCAPE_MAX_LEN; } +#ifdef __clang__ +# if __has_builtin(__builtin_ctzll) +# define HAVE_BUILTIN_CTZLL 1 +# else +# define HAVE_BUILTIN_CTZLL 0 +# endif +#elif defined(__GNUC__) && (__GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 3)) +# define HAVE_BUILTIN_CTZLL 1 +#else +# define HAVE_BUILTIN_CTZLL 0 +#endif + +#ifdef ERB_ENABLE_SIMD +#if defined(__amd64__) || defined(__amd64) || defined(__x86_64__) || defined(__x86_64) || defined(_M_X64) || defined(_M_AMD64) +#ifdef HAVE_X86INTRIN_H +#include +#define HAVE_SIMD 1 +#define HAVE_SIMD_SSE2 1 +#endif +#endif + +#if defined(__ARM_NEON) || defined(__ARM_NEON__) || defined(__aarch64__) || defined(_M_ARM64) +#define HAVE_SIMD 1 +#define HAVE_SIMD_NEON 1 +#include +#endif +#endif // ERB_ENABLE_SIMD + +typedef struct _search_state { + const unsigned char *cstr; + const unsigned char *end; + +#if defined(HAVE_SIMD_NEON) + uint64_t matches_bitmap; +#elif defined(HAVE_SIMD_SSE2) + int matches_bitmap; +#endif +} search_state; + +static inline bool +find_next_basic(search_state *search) +{ + while (search->cstr < search->end) { + const unsigned char c = *search->cstr; + if (html_escape_table[c].len) { + return true; + } + search->cstr++; + } + return false; +} + +#ifdef HAVE_SIMD_SSE2 + +static inline int trailing_zeros(int input) +{ + RUBY_ASSERT(input > 0); // __builtin_ctz(0) is undefined behavior + +#if HAVE_BUILTIN_CTZLL + return __builtin_ctz(input); +#else + int trailing_zeros = 0; + int temp = input; + while ((temp & 1) == 0 && temp > 0) { + trailing_zeros++; + temp >>= 1; + } + return trailing_zeros; +#endif +} + +static inline bool +find_next_match_sse2(search_state *search) +{ + int next_match_offset = trailing_zeros(search->matches_bitmap); + search->matches_bitmap >>= (next_match_offset + 1); + search->cstr += next_match_offset; + if (search->cstr > search->end) { + search->cstr = search->end; + return false; + } + return true; +} + +static inline bool +find_next_sse2(search_state *search) +{ + if (search->matches_bitmap) { + return find_next_match_sse2(search); + } + + const __m128i single_quote = _mm_set1_epi8('\''); + const __m128i double_quote = _mm_set1_epi8('"'); + const __m128i ampersand = _mm_set1_epi8('&'); + const __m128i lt = _mm_set1_epi8('<'); + const __m128i gt = _mm_set1_epi8('>'); + + while ((size_t)(search->end - search->cstr) >= sizeof(__m128i)) { + const __m128i bytes = _mm_loadu_si128((__m128i const *)search->cstr); + const __m128i match1 = _mm_cmpeq_epi8(bytes, single_quote); + const __m128i match2 = _mm_cmpeq_epi8(bytes, double_quote); + const __m128i match3 = _mm_cmpeq_epi8(bytes, ampersand); + const __m128i match4 = _mm_cmpeq_epi8(bytes, lt); + const __m128i match5 = _mm_cmpeq_epi8(bytes, gt); + + const __m128i mask1 = _mm_or_si128(match1, match2); + const __m128i mask2 = _mm_or_si128(match3, match4); + const __m128i mask3 = _mm_or_si128(mask1, match5); + const __m128i matches = _mm_or_si128(mask2, mask3); + + const int bitmap = _mm_movemask_epi8(matches); + + if (bitmap) { + search->matches_bitmap = bitmap; + return find_next_match_sse2(search); + } + search->cstr += sizeof(__m128i); + } + + return find_next_basic(search); +} +#define find_next find_next_sse2 +#endif + +#ifdef HAVE_SIMD_NEON +#ifndef __has_builtin // Optional of course. + #define __has_builtin(x) 0 // Compatibility with non-clang compilers. +#endif + +static inline uint32_t trailing_zeros64(uint64_t input) +{ +#if HAVE_BUILTIN_CTZLL + return __builtin_ctzll(input); +#else + uint32_t trailing_zeros = 0; + uint64_t temp = input; + while ((temp & 1) == 0 && temp > 0) { + trailing_zeros++; + temp >>= 1; + } + return trailing_zeros; +#endif +} + +static inline bool +find_next_match_neon(search_state *search) +{ + size_t next_match_offset = trailing_zeros64(search->matches_bitmap) / 4; + search->matches_bitmap >>= (next_match_offset + 1) * 4; + search->cstr += next_match_offset; + if (search->cstr > search->end) { + search->cstr = search->end; + return false; + } + return true; +} + +static inline bool +find_next_neon(search_state *search) +{ + if (search->matches_bitmap) { + return find_next_match_neon(search); + } + + const uint8x16_t single_quote = vdupq_n_u8('\''); + const uint8x16_t double_quote = vdupq_n_u8('"'); + const uint8x16_t ampersand = vdupq_n_u8('&'); + const uint8x16_t lt = vdupq_n_u8('<'); + const uint8x16_t gt = vdupq_n_u8('>'); + + while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16_t)) { + const uint8x16_t bytes = vld1q_u8(search->cstr); + const uint8x16_t match1 = vceqq_u8(bytes, single_quote); + const uint8x16_t match2 = vceqq_u8(bytes, double_quote); + const uint8x16_t match3 = vceqq_u8(bytes, ampersand); + const uint8x16_t match4 = vceqq_u8(bytes, lt); + const uint8x16_t match5 = vceqq_u8(bytes, gt); + + const uint8x16_t mask1 = vorrq_u8(match1, match2); + const uint8x16_t mask2 = vorrq_u8(match3, match4); + const uint8x16_t mask3 = vorrq_u8(mask1, match5); + const uint8x16_t matches = vorrq_u8(mask2, mask3); + + const uint8x8_t res = vshrn_n_u16(vreinterpretq_u16_u8(matches), 4); + const uint64_t bitmap = vget_lane_u64(vreinterpret_u64_u8(res), 0) & 0x8888888888888888ull; + + if (bitmap) { + search->matches_bitmap = bitmap; + return find_next_match_neon(search); + } + search->cstr += sizeof(uint8x16_t); + } + + return find_next_basic(search); +} + +#define find_next find_next_neon +#endif // HAVE_SIMD_NEON + +#ifndef find_next +#define find_next_basic +#endif + static VALUE optimized_escape_html(VALUE str) { VALUE vbuf; char *buf = NULL; - const char *cstr = RSTRING_PTR(str); - const char *end = cstr + RSTRING_LEN(str); + search_state search = { + .cstr = (const unsigned char *)RSTRING_PTR(str), + }; + search.end = search.cstr + RSTRING_LEN(str); - const char *segment_start = cstr; + const unsigned char *segment_start = search.cstr; char *dest = NULL; - while (cstr < end) { - const unsigned char c = *cstr++; + + while (find_next(&search)) { + const unsigned char c = *search.cstr; uint8_t len = html_escape_table[c].len; - if (len) { - size_t segment_len = cstr - segment_start - 1; - if (!buf) { - buf = ALLOCV_N(char, vbuf, escaped_length(str)); - dest = buf; - } - if (segment_len) { - memcpy(dest, segment_start, segment_len); - dest += segment_len; - } - segment_start = cstr; - memcpy(dest, html_escape_table[c].str, len); - dest += len; + size_t segment_len = search.cstr - segment_start; + search.cstr++; + + if (!buf) { + buf = ALLOCV_N(char, vbuf, escaped_length(str)); + dest = buf; } + if (segment_len) { + memcpy(dest, segment_start, segment_len); + dest += segment_len; + } + segment_start = search.cstr; + memcpy(dest, html_escape_table[c].str, len); + dest += len; } + VALUE escaped = str; if (buf) { - size_t segment_len = cstr - segment_start; + size_t segment_len = search.cstr - segment_start; if (segment_len) { memcpy(dest, segment_start, segment_len); dest += segment_len; diff --git a/ext/erb/escape/extconf.rb b/ext/erb/escape/extconf.rb index b211a9783f459e..f00147b8b9ce99 100644 --- a/ext/erb/escape/extconf.rb +++ b/ext/erb/escape/extconf.rb @@ -5,5 +5,27 @@ File.write('Makefile', dummy_makefile($srcdir).join) else have_func("rb_ext_ractor_safe", "ruby.h") + + case RbConfig::CONFIG['host_cpu'] + when /^(arm|aarch64)/ + # Try to compile a small program using NEON instructions + header, type, init, extra = 'arm_neon.h', 'uint8x16_t', 'vdupq_n_u8(32)', nil + when /^(x86_64|x64)/ + header, type, init, extra = 'x86intrin.h', '__m128i', '_mm_set1_epi8(32)', 'if (__builtin_cpu_supports("sse2")) { printf("OK"); }' + end + if header + if have_header(header) && try_compile(<<~SRC, '-Werror=implicit-function-declaration') + #{cpp_include(header)} + int main(int argc, char **argv) { + #{type} test = #{init}; + #{extra} + if (argc > 100000) printf("%p", &test); + return 0; + } + SRC + $defs.push("-DERB_ENABLE_SIMD") + end + end + create_makefile 'erb/escape' end diff --git a/test/erb/test_erb.rb b/test/erb/test_erb.rb index 6da25146ff7966..c789e074510df5 100644 --- a/test/erb/test_erb.rb +++ b/test/erb/test_erb.rb @@ -50,7 +50,7 @@ def test_html_escape assert_equal("", ERB::Util.html_escape("")) assert_equal("abc", ERB::Util.html_escape("abc")) assert_equal("<<", ERB::Util.html_escape("<\<")) - assert_equal("'&"><", ERB::Util.html_escape("'&\"><")) + assert_equal("'&"><" * 10, ERB::Util.html_escape("'&\"><" * 10)) assert_equal("", ERB::Util.html_escape(nil)) assert_equal("123", ERB::Util.html_escape(123)) From 1bb0450e95201301cc7acd62d35e7096f8e9b636 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 9 Sep 2026 07:47:43 +0100 Subject: [PATCH 36/36] [ruby/erb] Optimize escaping with memcpy of literals (https://github.com/ruby/erb/pull/137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `memcpy(dest, html_escape_table[c].str, len);` will hardly be able to be inlined as the compiler has no idea how large `len` may be. By turning it into constants, we allow the compiler to realize these strings are very small, and it will most likely inline the `memcpy`. It also reduce the size of `html_escape_table` from `2kiB` down to just `256B`, which is good for caches. ``` == 1k no matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- memcpy 1.410M i/100ms Calculating ------------------------------------- memcpy 14.681M (± 0.6%) i/s (68.12 ns/i) - 74.709M in 5.088841s Comparison: simd: 14726967.2 i/s memcpy: 14681042.1 i/s - same-ish: difference falls within error == 1k few matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- memcpy 213.724k i/100ms Calculating ------------------------------------- memcpy 2.167M (± 2.2%) i/s (461.54 ns/i) - 10.900M in 5.030772s Comparison: simd: 1695817.9 i/s memcpy: 2166650.4 i/s - 1.28x faster == 1k many matches == ruby 4.0.6 (2026-07-14 revision https://github.com/ruby/erb/commit/03b6d3f889) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- memcpy 201.591k i/100ms Calculating ------------------------------------- memcpy 1.982M (± 7.2%) i/s (504.63 ns/i) - 10.080M in 5.086446s Comparison: simd: 1578725.7 i/s memcpy: 1981648.9 i/s - 1.26x faster ``` https://github.com/ruby/erb/commit/a26b4ef602 --- ext/erb/escape/escape.c | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index d4a53d0eb13714..19d8964823d573 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -6,17 +6,12 @@ static ID id_escapeHTML; #define HTML_ESCAPE_MAX_LEN 6 -static const struct { - uint8_t len; - char str[HTML_ESCAPE_MAX_LEN+1]; -} html_escape_table[UCHAR_MAX+1] = { -#define HTML_ESCAPE(c, str) [c] = {rb_strlen_lit(str), str} - HTML_ESCAPE('\'', "'"), - HTML_ESCAPE('&', "&"), - HTML_ESCAPE('"', """), - HTML_ESCAPE('<', "<"), - HTML_ESCAPE('>', ">"), -#undef HTML_ESCAPE +static const bool html_escape_table[UCHAR_MAX+1] = { + ['\''] = true, + ['&'] = true, + ['"'] = true, + ['<'] = true, + ['>'] = true, }; static inline long @@ -72,8 +67,7 @@ static inline bool find_next_basic(search_state *search) { while (search->cstr < search->end) { - const unsigned char c = *search->cstr; - if (html_escape_table[c].len) { + if (html_escape_table[*search->cstr]) { return true; } search->cstr++; @@ -247,7 +241,6 @@ optimized_escape_html(VALUE str) while (find_next(&search)) { const unsigned char c = *search.cstr; - uint8_t len = html_escape_table[c].len; size_t segment_len = search.cstr - segment_start; search.cstr++; @@ -260,8 +253,24 @@ optimized_escape_html(VALUE str) dest += segment_len; } segment_start = search.cstr; - memcpy(dest, html_escape_table[c].str, len); - dest += len; + + switch(c) { + #define HTML_ESCAPE(c, str) \ + case c: \ + memcpy(dest, str, rb_strlen_lit(str)); \ + dest += rb_strlen_lit(str); \ + break + + HTML_ESCAPE('\'', "'"); + HTML_ESCAPE('&', "&"); + HTML_ESCAPE('"', """); + HTML_ESCAPE('<', "<"); + HTML_ESCAPE('>', ">"); + default: + UNREACHABLE_RETURN(Qundef); + + #undef HTML_ESCAPE + } } VALUE escaped = str;