From 3ee17ab904ce0d2af4dd618339f90b3937dd23c4 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Sun, 13 Sep 2026 09:13:25 -0700 Subject: [PATCH 01/22] Make Test::Unit stop aliasing method in prepended module Ruby will be emitting a deprecation warning for this shortly. Switch to using super instead of an alias. --- tool/lib/test/unit.rb | 2 -- tool/lib/test/unit/parallel.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tool/lib/test/unit.rb b/tool/lib/test/unit.rb index 04e48621293670..f3a52c41ce9004 100644 --- a/tool/lib/test/unit.rb +++ b/tool/lib/test/unit.rb @@ -1825,8 +1825,6 @@ def self.autorun @@installed_at_exit = true end - alias orig_run_suite _run_suite - # Overriding of Test::Unit::Runner#puke def puke klass, meth, e n = report.size diff --git a/tool/lib/test/unit/parallel.rb b/tool/lib/test/unit/parallel.rb index 188a0d1a191a2b..1b84ffedfd9208 100644 --- a/tool/lib/test/unit/parallel.rb +++ b/tool/lib/test/unit/parallel.rb @@ -49,7 +49,7 @@ def _run_suite(suite, type) # :nodoc: e, f, s = @errors, @failures, @skips begin - result = orig_run_suite(suite, type) + result = super rescue Interrupt @need_exit = true result = [nil,nil] From 1c13d07cfca130ae3c0631d24ef21680099c02a7 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Sun, 13 Sep 2026 08:56:23 -0700 Subject: [PATCH 02/22] Emit deprecation warning for aliasing method in prepended module Aliasing a method in a prepended module can result in a super call going into a descendant instead of an ancestor. Removal plan: 4.1: Deprecation warning 4.2: Warning even in non-verbose mode 4.3: Removal (target method lookup starts at origin class) Fixes [Bug #22273] --- NEWS.md | 7 +++++++ test/ruby/test_module.rb | 24 ++++++++++++++++++++++++ vm_method.c | 22 ++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/NEWS.md b/NEWS.md index 4e064a174aac79..a6ebfa69a5f508 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,12 @@ Note that each entry is kept to a minimum, see links for details. removed in Ruby 4.3, and such an alias will raise a `NameError`. [[Bug #22276]] +* `alias` and `Module#alias_method` emit a deprecation warning when the + original method is defined in a prepended module. This behavior will + be removed in Ruby 4.3, and alias lookup will start at the origin + class (after prepended modules). + [[Bug #22273]] + ## Core classes updates Note: We're only listing outstanding class updates. @@ -471,6 +477,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [Bug #18661]: https://bugs.ruby-lang.org/issues/18661 [Bug #18947]: https://bugs.ruby-lang.org/issues/18947 +[Bug #22273]: https://bugs.ruby-lang.org/issues/22273 [Bug #22276]: https://bugs.ruby-lang.org/issues/22276 [Feature #8948]: https://bugs.ruby-lang.org/issues/8948 [Feature #9779]: https://bugs.ruby-lang.org/issues/9779 diff --git a/test/ruby/test_module.rb b/test/ruby/test_module.rb index c011da4a5c5d1c..00e3d83327ef31 100644 --- a/test/ruby/test_module.rb +++ b/test/ruby/test_module.rb @@ -1587,6 +1587,30 @@ def bar; 2; end INPUT end + def test_alias_prepended_module_warning + assert_in_out_err([], <<-INPUT, [], /aliasing C#foo defined in a prepended module M is deprecated/) + Warning[:deprecated] = true + module M + def foo = :foo + end + class C + prepend M + alias bar foo + end + INPUT + + assert_in_out_err([], <<-INPUT, [], /aliasing C#foo defined in a prepended module M is deprecated/) + Warning[:deprecated] = true + module M + def foo = :foo + end + class C + prepend M + alias_method :bar, :foo + end + INPUT + end + def test_mod_constants m = Module.new m.const_set(:Foo, :foo) diff --git a/vm_method.c b/vm_method.c index a63531e90e28d6..9160d4a9ef67cf 100644 --- a/vm_method.c +++ b/vm_method.c @@ -2981,6 +2981,28 @@ rb_alias(VALUE klass, ID alias_name, ID original_name) if (visi == METHOD_VISI_UNDEF) visi = METHOD_ENTRY_VISI(orig_me); + if (!NIL_P(ruby_verbose) && rb_warning_category_enabled_p(RB_WARN_CATEGORY_DEPRECATED)) { + VALUE owner_class = orig_me->defined_class ? orig_me->defined_class : defined_class; + VALUE origin = RCLASS_ORIGIN(target_klass); + bool in_prepended_module = false; + + if (origin != target_klass) { + for (VALUE p = RCLASS_SUPER(target_klass); !in_prepended_module && p && p != origin; p = RCLASS_SUPER(p)) { + if (p == owner_class) { + in_prepended_module = true; + } + } + } + + if (in_prepended_module) { + rb_warn_deprecated_to_remove_at(4.3, + "aliasing %"PRIsVALUE"#%"PRIsVALUE" defined in a prepended module %"PRIsVALUE, + NULL, + rb_class_path(target_klass), QUOTE_ID(original_name), + rb_class_path(orig_me->owner)); + } + } + if (orig_me->defined_class == 0) { struct method_entry_warnings warnings = {0}; const rb_method_entry_t *alias_me = From abac8a5b6e19b2769d663eb9e1689f8b521d660a Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Sun, 13 Sep 2026 09:31:47 -0700 Subject: [PATCH 03/22] Update tests and specs for alias method in prepended module deprecation --- spec/ruby/core/module/prepend_spec.rb | 42 ++++++++++++++++++++++----- test/ruby/test_method.rb | 4 ++- test/ruby/test_module.rb | 4 ++- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/spec/ruby/core/module/prepend_spec.rb b/spec/ruby/core/module/prepend_spec.rb index f7887e6d6ab01a..a9cc97f20ab491 100644 --- a/spec/ruby/core/module/prepend_spec.rb +++ b/spec/ruby/core/module/prepend_spec.rb @@ -521,10 +521,23 @@ class ModuleSpecs::SubclassSpec::AClass c.public_instance_method(:meth).owner.should == m end - it "causes the prepended module's method to be aliased by alias_method" do - m = Module.new { def meth; :m end } - c = Class.new { def meth; :c end; prepend(m); alias_method :alias, :meth } - c.new.alias.should == :m + ruby_version_is '4.1' do + it "causes the prepended module's method to be aliased by alias_method" do + m = Module.new { def meth; :m end } + c = Class.new { def meth; :c end; prepend(m) } + -> { + c.send(:alias_method, :alias, :meth) + }.should complain(/aliasing .*#meth defined in a prepended module .* is deprecated/) + c.new.alias.should == :m + end + end + + ruby_version_is ''...'4.1' do + it "causes the prepended module's method to be aliased by alias_method" do + m = Module.new { def meth; :m end } + c = Class.new { def meth; :c end; prepend(m); alias_method :alias, :meth } + c.new.alias.should == :m + end end it "reports the class for the owner of an aliased method on the class" do @@ -533,10 +546,23 @@ class ModuleSpecs::SubclassSpec::AClass c.instance_method(:alias).owner.should == c end - it "reports the class for the owner of a method aliased from the prepended module" do - m = Module.new { def meth; :m end } - c = Class.new { prepend(m); alias_method :alias, :meth } - c.instance_method(:alias).owner.should == c + ruby_version_is '4.1' do + it "reports the class for the owner of a method aliased from the prepended module" do + m = Module.new { def meth; :m end } + c = Class.new { prepend(m) } + -> { + c.send(:alias_method, :alias, :meth) + }.should complain(/aliasing .*#meth defined in a prepended module .* is deprecated/) + c.instance_method(:alias).owner.should == c + end + end + + ruby_version_is ''...'4.1' do + it "reports the class for the owner of a method aliased from the prepended module" do + m = Module.new { def meth; :m end } + c = Class.new { prepend(m); alias_method :alias, :meth } + c.instance_method(:alias).owner.should == c + end end it "sees an instance of a prepended class as kind of the prepended module" do diff --git a/test/ruby/test_method.rb b/test/ruby/test_method.rb index b9e4c320f45e45..041de2610e356d 100644 --- a/test/ruby/test_method.rb +++ b/test/ruby/test_method.rb @@ -1290,7 +1290,9 @@ def m1 [:C1_m1] + super end prepend m - alias m2 m1 + end + assert_deprecated_warning(/aliasing .*#m1 defined in a prepended module .* is deprecated/) do + c1.class_eval { alias m2 m1 } end o1 = c1.new diff --git a/test/ruby/test_module.rb b/test/ruby/test_module.rb index 00e3d83327ef31..347d4e88db855e 100644 --- a/test/ruby/test_module.rb +++ b/test/ruby/test_module.rb @@ -2805,7 +2805,9 @@ def m; "A"; end def m; "B"+super; end alias m2 m prepend p - alias m3 m + end + assert_deprecated_warning(/aliasing .*#m defined in a prepended module .* is deprecated/) do + b.class_eval { alias m3 m } end assert_equal("BA", b.new.m2, bug7842) assert_equal("PBA", b.new.m3, bug7842) From e755b92a3f6bdae617c5c548420058b861e294bd Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Tue, 15 Sep 2026 17:57:00 -0400 Subject: [PATCH 04/22] Call dfree on non-thread-safe T_DATA with the VM barrier When running multiple Ractors, we can't call dfree of a non-thread-safe T_DATA while other Ractors are running or doing GC work. If there are multiple Ractors, we save the T_DATA object's `dfree` and `data` pointer in an entry. Once the entry buffer is full per-Ractor, we publish it to the global list atomically. Once the threshold for these deferred T_DATAs is reached (right now ~65K), the next Ractor to run interrupts calls the `dfree` functions for these objects under the VM barrier. It does not do any other GC work during this postponed job. If a global GC runs, it also calls the `dfree` functions for all these objects (it drains the deferred-free queue). When only 1 Ractor is running (`rb_gc_single_objspace_p()`), these objects are freed normally and aren't added to any queue. NOTE ---- Embedded T_DATA objects need to be special-cased because their `data` points to inside the object's slot itself, which we reuse right away now. For non-thread-safe T_DATA that is embeddable, we need to create it as unembedded - even without Ractors. --- .../gc/tdata_non_thread_safe_free/extconf.rb | 2 + .../tdata_non_thread_safe_free.c | 288 ++++++++++++ gc.c | 16 +- gc/default/default.c | 409 +++++++++++++++++- gc/gc.h | 12 + include/ruby/internal/core/rtypeddata.h | 6 +- ractor.c | 1 + .../gc/test_tdata_non_thread_safe_free.rb | 294 +++++++++++++ 8 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 ext/-test-/gc/tdata_non_thread_safe_free/extconf.rb create mode 100644 ext/-test-/gc/tdata_non_thread_safe_free/tdata_non_thread_safe_free.c create mode 100644 test/-ext-/gc/test_tdata_non_thread_safe_free.rb diff --git a/ext/-test-/gc/tdata_non_thread_safe_free/extconf.rb b/ext/-test-/gc/tdata_non_thread_safe_free/extconf.rb new file mode 100644 index 00000000000000..74852e7532f4a9 --- /dev/null +++ b/ext/-test-/gc/tdata_non_thread_safe_free/extconf.rb @@ -0,0 +1,2 @@ +# frozen_string_literal: false +create_makefile("-test-/gc/tdata_non_thread_safe_free") diff --git a/ext/-test-/gc/tdata_non_thread_safe_free/tdata_non_thread_safe_free.c b/ext/-test-/gc/tdata_non_thread_safe_free/tdata_non_thread_safe_free.c new file mode 100644 index 00000000000000..ecdd6424aa314f --- /dev/null +++ b/ext/-test-/gc/tdata_non_thread_safe_free/tdata_non_thread_safe_free.c @@ -0,0 +1,288 @@ +#include +#include + +/* + * T_DATA types whose free functions are declared RUBY_TYPED_FREE_IMMEDIATELY but + * deliberately NOT RUBY_TYPED_THREAD_SAFE_FREE. Without that flag the GC must + * not invoke dfree concurrently with another dfree, even though Ractor-local GC + * lets several Ractors mark/sweep in parallel. A genuinely non-thread-safe dfree + * would touch shared process state without a lock and corrupt it under concurrent + * invocation. Here we measure the unsafe precondition by counting how many threads + * are inside the free at the same instant. + * + * A plain one plus an embeddable pair, small enough that the payload would fit in the + * slot. A deferred free has to outlive the slot, so the non-thread-safe half of the + * pair must be denied embedding; the thread-safe half is identical apart from the flag + * and must still be embedded. A fourth wraps a NULL payload, which gets no dfree at all. + */ + +static rb_atomic_t in_free_now; +static rb_atomic_t max_concurrent_free; +static rb_atomic_t total_frees; +static rb_atomic_t embeddable_frees; +static rb_atomic_t null_payload_frees; +static rb_atomic_t null_payload_null_frees; + +/* How long to hold the free window open, in atomic-load spins, so a concurrent + * free on another Ractor becomes observable. */ +#define OVERLAP_WINDOW 128 + +static void +free_enter(void) +{ + rb_atomic_t cur = RUBY_ATOMIC_FETCH_ADD(in_free_now, 1) + 1; + + rb_atomic_t prev; + do { + prev = RUBY_ATOMIC_LOAD(max_concurrent_free); + if (cur <= prev) break; + } while (RUBY_ATOMIC_CAS(max_concurrent_free, prev, cur) != prev); + + for (int i = 0; i < OVERLAP_WINDOW; i++) { + if (RUBY_ATOMIC_LOAD(in_free_now) >= 2) break; + } +} + +static void +free_leave(rb_atomic_t *counter) +{ + RUBY_ATOMIC_FETCH_SUB(in_free_now, 1); + RUBY_ATOMIC_FETCH_ADD(*counter, 1); +} + +static void +non_thread_safe_free(void *ptr) +{ + free_enter(); + free_leave(&total_frees); + xfree(ptr); +} + +/* No xfree: the GC frees the buffer of an embeddable type that was not embedded. */ +static void +non_thread_safe_free_embeddable(void *ptr) +{ + free_enter(); + free_leave(&embeddable_frees); +} + +static void +thread_safe_free_embeddable(void *ptr) +{ + /* Only here to make the control type differ from the one above by its flags alone. */ +} + +typedef struct { + int payload; +} test_data; + +typedef struct { + char payload[8]; +} embeddable_data; + +/* intentionally NOT RUBY_TYPED_THREAD_SAFE_FREE */ +static const rb_data_type_t non_thread_safe_free_type = { + "tdata_non_thread_safe_free", + {0, non_thread_safe_free, 0}, + 0, 0, + RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static const rb_data_type_t non_thread_safe_free_embeddable_type = { + "tdata_non_thread_safe_free_embeddable", + {0, non_thread_safe_free_embeddable, 0}, + 0, 0, + RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_EMBEDDABLE, +}; + +static const rb_data_type_t thread_safe_free_embeddable_type = { + "tdata_thread_safe_free_embeddable", + {0, thread_safe_free_embeddable, 0}, + 0, 0, + RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_THREAD_SAFE_FREE | RUBY_TYPED_EMBEDDABLE, +}; + +/* Mirrors an allocator that wraps a NULL pointer and fills it in during initialize. + * rb_data_free runs no dfree at all for a NULL payload, so the deferred free path must + * not queue one either; null_payload_null_frees counts the calls that must never happen. */ +static void +null_payload_free(void *ptr) +{ + if (ptr == NULL) { + RUBY_ATOMIC_FETCH_ADD(null_payload_null_frees, 1); + return; + } + free_enter(); + free_leave(&null_payload_frees); + xfree(ptr); +} + +/* intentionally NOT RUBY_TYPED_THREAD_SAFE_FREE */ +static const rb_data_type_t null_payload_type = { + "tdata_null_payload", + {0, null_payload_free, 0}, + 0, 0, + RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static VALUE cEmbeddable, cThreadSafeEmbeddable, cNullPayload; + +static bool +embedded_p(VALUE obj) +{ + return RTYPEDDATA_GET_DATA(obj) == (void *)&RTYPEDDATA(obj)->data; +} + +static VALUE +test_alloc(VALUE klass) +{ + test_data *data; + return TypedData_Make_Struct(klass, test_data, &non_thread_safe_free_type, data); +} + +static VALUE +test_make(VALUE klass, VALUE num) +{ + unsigned long i, n = NUM2ULONG(num); + for (i = 0; i < n; i++) { + test_alloc(klass); + } + return Qnil; +} + +static VALUE +test_make_embeddable(VALUE klass, VALUE num) +{ + unsigned long i, n = NUM2ULONG(num); + for (i = 0; i < n; i++) { + embeddable_data *data; + TypedData_Make_Struct(cEmbeddable, embeddable_data, + &non_thread_safe_free_embeddable_type, data); + } + return Qnil; +} + +static VALUE +test_embeddable_embedded_p(VALUE klass) +{ + embeddable_data *data; + VALUE obj = TypedData_Make_Struct(cEmbeddable, embeddable_data, + &non_thread_safe_free_embeddable_type, data); + return embedded_p(obj) ? Qtrue : Qfalse; +} + +static VALUE +test_thread_safe_embeddable_embedded_p(VALUE klass) +{ + embeddable_data *data; + VALUE obj = TypedData_Make_Struct(cThreadSafeEmbeddable, embeddable_data, + &thread_safe_free_embeddable_type, data); + return embedded_p(obj) ? Qtrue : Qfalse; +} + +static VALUE +null_payload_alloc(VALUE klass) +{ + return TypedData_Wrap_Struct(klass, &null_payload_type, 0); +} + +static VALUE +test_make_null_payload(VALUE klass, VALUE num) +{ + unsigned long i, n = NUM2ULONG(num); + for (i = 0; i < n; i++) { + null_payload_alloc(cNullPayload); + } + return Qnil; +} + +/* The same type carrying a real payload, so a test can tell "dfree was never called with + * NULL" apart from "dfree was never called". */ +static VALUE +test_make_filled_payload(VALUE klass, VALUE num) +{ + unsigned long i, n = NUM2ULONG(num); + for (i = 0; i < n; i++) { + test_data *data; + TypedData_Make_Struct(cNullPayload, test_data, &null_payload_type, data); + } + return Qnil; +} + +static VALUE +test_null_payload_frees(VALUE klass) +{ + return UINT2NUM(RUBY_ATOMIC_LOAD(null_payload_frees)); +} + +static VALUE +test_null_payload_null_frees(VALUE klass) +{ + return UINT2NUM(RUBY_ATOMIC_LOAD(null_payload_null_frees)); +} + +static VALUE +test_max_concurrent_free(VALUE klass) +{ + return UINT2NUM(RUBY_ATOMIC_LOAD(max_concurrent_free)); +} + +static VALUE +test_total_frees(VALUE klass) +{ + return UINT2NUM(RUBY_ATOMIC_LOAD(total_frees)); +} + +static VALUE +test_embeddable_frees(VALUE klass) +{ + return UINT2NUM(RUBY_ATOMIC_LOAD(embeddable_frees)); +} + +static VALUE +test_reset(VALUE klass) +{ + RUBY_ATOMIC_SET(in_free_now, 0); + RUBY_ATOMIC_SET(max_concurrent_free, 0); + RUBY_ATOMIC_SET(total_frees, 0); + RUBY_ATOMIC_SET(embeddable_frees, 0); + RUBY_ATOMIC_SET(null_payload_frees, 0); + RUBY_ATOMIC_SET(null_payload_null_frees, 0); + return Qnil; +} + +void +Init_tdata_non_thread_safe_free(void) +{ + rb_ext_ractor_safe(true); + + VALUE mBug = rb_define_module("Bug"); + VALUE klass = rb_define_class_under(mBug, "TDataNonThreadSafeFree", rb_cObject); + rb_define_alloc_func(klass, test_alloc); + rb_define_singleton_method(klass, "make", test_make, 1); + rb_define_singleton_method(klass, "make_embeddable", test_make_embeddable, 1); + rb_define_singleton_method(klass, "embeddable_embedded?", test_embeddable_embedded_p, 0); + rb_define_singleton_method(klass, "thread_safe_embeddable_embedded?", + test_thread_safe_embeddable_embedded_p, 0); + rb_define_singleton_method(klass, "max_concurrent_free", test_max_concurrent_free, 0); + rb_define_singleton_method(klass, "total_frees", test_total_frees, 0); + rb_define_singleton_method(klass, "embeddable_frees", test_embeddable_frees, 0); + rb_define_singleton_method(klass, "make_null_payload", test_make_null_payload, 1); + rb_define_singleton_method(klass, "make_filled_payload", test_make_filled_payload, 1); + rb_define_singleton_method(klass, "null_payload_frees", test_null_payload_frees, 0); + rb_define_singleton_method(klass, "null_payload_null_frees", + test_null_payload_null_frees, 0); + rb_define_singleton_method(klass, "reset", test_reset, 0); + + rb_gc_register_address(&cEmbeddable); + rb_gc_register_address(&cThreadSafeEmbeddable); + rb_gc_register_address(&cNullPayload); + cEmbeddable = rb_define_class_under(klass, "Embeddable", rb_cObject); + cThreadSafeEmbeddable = rb_define_class_under(klass, "ThreadSafeEmbeddable", rb_cObject); + cNullPayload = rb_define_class_under(klass, "NullPayload", rb_cObject); + rb_define_alloc_func(cNullPayload, null_payload_alloc); + /* Wrapped but never allocated from Ruby: without this the first wrap trips + * rb_data_object_check, which undefines the inherited allocator and warns. */ + rb_undef_alloc_func(cEmbeddable); + rb_undef_alloc_func(cThreadSafeEmbeddable); +} diff --git a/gc.c b/gc.c index fdb23a8e435520..b714a5394321a3 100644 --- a/gc.c +++ b/gc.c @@ -123,6 +123,7 @@ #include "vm_sync.h" #include "vm_callinfo.h" #include "ractor_core.h" +#include "internal/ractor.h" #include "yjit.h" #include "zjit.h" @@ -344,6 +345,12 @@ rb_gc_trigger_finalize_deferred(void *objspace, rb_postponed_job_handle_t pjob) rb_postponed_job_trigger(pjob); } +void +rb_gc_trigger_postponed_job_on_main(rb_postponed_job_handle_t pjob) +{ + rb_postponed_job_trigger_for_ractor(pjob, GET_VM()->ractor.main_ractor->pub.self); +} + void rb_gc_unset_pending_interrupt(void) { @@ -1389,8 +1396,12 @@ typed_data_zalloc_in(void *objspace, VALUE klass, size_t size, const rb_data_typ rb_raise(rb_eTypeError, "Embeddable TypedData must be freed immediately"); } + /* A deferred free outlives the slot: the sweep copies the type and data pointer + * out and hands the slot straight back, which it can only do when the payload is + * not in the slot. Force such a type onto the heap -- an embeddable type already + * has to cope with that, since a payload too large for a slot lands there too. */ size_t embed_size = offsetof(struct RTypedData, data) + size; - if (rb_gc_size_allocatable_p(embed_size)) { + if (rb_gc_size_allocatable_p(embed_size) && !rb_gc_data_type_deferred_free_p(type)) { 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; @@ -4040,6 +4051,7 @@ static void gc_orphan_merge_job(void *unused); static void zombie_objspaces_push(rb_vm_t *vm, void *objspace, void **owner_slot, struct rb_ractor_struct *owner) { + ASSERT_vm_locking(); if (vm->gc.zombie_objspaces_count == vm->gc.zombie_objspaces_capa) { size_t new_capa = vm->gc.zombie_objspaces_capa ? vm->gc.zombie_objspaces_capa * 2 : 16; struct rb_objspace_zombie *grown = @@ -4118,6 +4130,7 @@ void rb_gc_objspace_disown(void *objspace) { if (!rb_gc_impl_multi_objspace_p()) return; + ASSERT_vm_locking(); rb_vm_t *vm = GET_VM(); bool found = false; @@ -4152,6 +4165,7 @@ rb_gc_during_global_gc_p(void) static void rb_gc_vm_forget_zombie(void *objspace) { + ASSERT_vm_locking(); rb_vm_t *vm = GET_VM(); size_t n = vm->gc.zombie_objspaces_count; for (size_t i = 0; i < n; i++) { diff --git a/gc/default/default.c b/gc/default/default.c index 776eb818cbc7b7..a7bc69371608f2 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -38,6 +38,7 @@ #include "gc/gc_impl.h" #include "yjit.h" #include "zjit.h" +#include "internal/static_assert.h" #include "internal/vm_map.h" #ifdef BUILDING_MODULAR_GC @@ -577,6 +578,33 @@ struct gc_malloc_bytes { gc_counter_t free_at_last_gc; }; +/* -- Deferred free of non-thread-safe T_DATA -- + * + * A dead T_DATA not RUBY_TYPED_THREAD_SAFE_FREE cannot have its dfree run during a parallel + * local sweep: the dfree is extension code that may touch process state other Ractors are using, + * so it needs the world stopped (not merely serialization against other dfrees). The sweep + * therefore copies out what the free needs, reclaims the slot immediately, and the dfrees are + * called later under a VM barrier. Such a type is never embedded, so the payload always outlives + * the slot. + */ +#define TDATA_UNSAFE_FREE_CHUNK_CAPA 32 +/* Drained chunks kept for reuse; the rest are freed. */ +#define TDATA_UNSAFE_FREE_CACHE_MAX 64 + +struct tdata_unsafe_free_entry { + void (*dfree)(void *); + void *data; +}; + +struct tdata_unsafe_free_chunk { + struct tdata_unsafe_free_chunk *next; + unsigned int count; + uint32_t embed_xfree_bits; + struct tdata_unsafe_free_entry entries[TDATA_UNSAFE_FREE_CHUNK_CAPA]; +}; +STATIC_ASSERT(tdata_unsafe_free_bits_cover_chunk, + TDATA_UNSAFE_FREE_CHUNK_CAPA <= 32); + typedef struct rb_objspace { struct { struct gc_malloc_bytes counters; @@ -768,6 +796,8 @@ typedef struct rb_objspace { rb_darray(VALUE) weak_references; rb_postponed_job_handle_t finalize_deferred_pjob; + /* Partially filled chunk of deferred non-thread-safe T_DATA metadata. */ + struct tdata_unsafe_free_chunk *tdata_unsafe_free_chunk; int sweeping_heap_count; @@ -839,11 +869,33 @@ typedef struct rb_global_objspace { size_t n_pages, capa; uintptr_t lomem, himem; } page_index; + + rb_postponed_job_handle_t tdata_deferred_free_pjob; /* atomic */ + + /* Pending count of deferred non-thread-safe T_DATA frees across all objspaces: + * bumped as each one is deferred, reset to 0 by the drain. Crossing + * TDATA_DEFERRED_FREE_THRESHOLD triggers the postponed job. */ + size_t tdata_deferred_free_count; /* atomic */ + + /* Full chunks awaiting a drain (CAS stack), and drained chunks kept for reuse. */ + struct tdata_unsafe_free_chunk *tdata_unsafe_free_published; /* atomic */ + struct tdata_unsafe_free_chunk *tdata_unsafe_free_cache; /* atomic */ + size_t tdata_unsafe_free_cache_len; /* atomic */ } rb_global_objspace_t; static rb_global_objspace_t rb_global_objspace_instance; static rb_global_objspace_t *global_objspace = NULL; +/* Relaxed: every reader only asks whether a drain is worth arranging, and the drain + * itself stops the world before it touches a chunk. There is no atomic size_t load, so + * go through the VALUE one (both are word sized). */ +static inline size_t +tdata_deferred_free_count_load(void) +{ + return (size_t)rbimpl_atomic_value_load( + (volatile VALUE *)&global_objspace->tdata_deferred_free_count, RBIMPL_ATOMIC_RELAXED); +} + /* The floor keeps a global GC from running as soon as a few shareable objects appear; * the factor follows the rule used for the old-generation limit. */ #define SHAREABLE_OBJECTS_LIMIT_MIN (1 << 16) @@ -852,6 +904,9 @@ static rb_global_objspace_t *global_objspace = NULL; * small Ractor's objspace is about 13 pages, so discarding many of them still stays * below it, while a single fat zombie crosses it. */ #define ZOMBIE_PAGES_TRIGGER 256 +/* Trigger the deferred T_DATA free postponed job once this many have accumulated + * across all objspaces. */ +#define TDATA_DEFERRED_FREE_THRESHOLD (1 << 15) static void objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src); @@ -895,6 +950,10 @@ global_objspace_init(void) g->page_pool.arena_current = NULL; g->page_pool.arena_count = 0; g->page_pool.advised_count = 0; + g->tdata_deferred_free_pjob = POSTPONED_JOB_HANDLE_INVALID; + g->tdata_unsafe_free_published = NULL; + g->tdata_unsafe_free_cache = NULL; + g->tdata_unsafe_free_cache_len = 0; g->page_pool.arenas_unmapped = 0; #ifdef HAVE_MMAP g->page_pool.os_page_size = sysconf(_SC_PAGE_SIZE); @@ -1555,6 +1614,7 @@ static inline void gc_prof_set_heap_info(rb_objspace_t *); PRINTF_ARGS(static void gc_report_body(int level, rb_objspace_t *objspace, const char *fmt, ...), 3, 4); static void gc_finalize_deferred(void *dmy); +static void gc_tdata_unsafe_drain_objspaces(rb_objspace_t **objspaces, size_t n); #if USE_TICK_T @@ -3597,6 +3657,8 @@ rb_gc_impl_live_object_p(void *objspace_ptr, const void *ptr) return live; } +/* Flags preserved from the original object when it becomes a zombie, and so also the + * only ones that may legitimately be set on one. */ #define ZOMBIE_OBJ_KEPT_FLAGS (FL_FINALIZE) void @@ -3619,6 +3681,110 @@ rb_gc_impl_make_zombie(void *objspace_ptr, VALUE obj, void (*dfree)(void *), voi page->heap->final_slots_count++; } +static void +tdata_unsafe_free_chunk_reset(struct tdata_unsafe_free_chunk *chunk) +{ + chunk->next = NULL; + chunk->count = 0; + chunk->embed_xfree_bits = 0; +} + +static struct tdata_unsafe_free_chunk * +tdata_unsafe_free_chunk_alloc(void) +{ + /* Pops race each other (several Ractors can be sweeping), but pushes happen only + * inside the drain, which holds a VM barrier -- and a barrier cannot complete while + * a Ractor is inside gc_sweep_page. No push ever overlaps a pop, so the head only + * moves forward and this CAS pop needs no ABA tagging. A sweep performed by a + * thread other than the objspace's owner would break that. */ + struct tdata_unsafe_free_chunk *head = + rbimpl_atomic_ptr_load((void **)&global_objspace->tdata_unsafe_free_cache, + RBIMPL_ATOMIC_ACQUIRE); + while (head) { + struct tdata_unsafe_free_chunk *prev = + rbimpl_atomic_ptr_cas((void **)&global_objspace->tdata_unsafe_free_cache, + head, head->next, + RBIMPL_ATOMIC_ACQ_REL, RBIMPL_ATOMIC_ACQUIRE); + if (prev == head) { + rbimpl_atomic_size_dec(&global_objspace->tdata_unsafe_free_cache_len, + RBIMPL_ATOMIC_RELAXED); + tdata_unsafe_free_chunk_reset(head); + return head; + } + head = prev; + } + + /* Not xmalloc: this runs mid-sweep, and the chunks are GC bookkeeping that should not + * feed back into malloc_increase (mark stack chunks do the same). */ + struct tdata_unsafe_free_chunk *chunk = malloc(sizeof(struct tdata_unsafe_free_chunk)); + if (!chunk) rb_memerror(); + tdata_unsafe_free_chunk_reset(chunk); + return chunk; +} + +/* Hand this objspace's partial chunk to the global stack. The entries were counted as + * they were appended, so the pending count does not change here. */ +static void +gc_tdata_unsafe_free_publish(rb_objspace_t *objspace) +{ + struct tdata_unsafe_free_chunk *chunk = objspace->tdata_unsafe_free_chunk; + if (chunk == NULL) return; + GC_ASSERT(chunk->count > 0); + objspace->tdata_unsafe_free_chunk = NULL; + + struct tdata_unsafe_free_chunk *prev, *head = + rbimpl_atomic_ptr_load((void **)&global_objspace->tdata_unsafe_free_published, + RBIMPL_ATOMIC_RELAXED); + do { + chunk->next = prev = head; + head = rbimpl_atomic_ptr_cas((void **)&global_objspace->tdata_unsafe_free_published, + prev, chunk, + RBIMPL_ATOMIC_ACQ_REL, RBIMPL_ATOMIC_ACQUIRE); + } while (head != prev); +} + +/* Copy out what obj's deferred free needs, running no dfree. Returns true when the + * caller may reclaim the slot and false when obj became a zombie, matching rb_gc_obj_free. */ +static bool +gc_defer_thread_unsafe_free(rb_objspace_t *objspace, VALUE obj, bool *trigger) +{ + GC_ASSERT(!((uintptr_t)RTYPEDDATA(obj)->type & TYPED_DATA_EMBEDDED)); + const rb_data_type_t *type = RTYPEDDATA_TYPE(obj); + void *data = RTYPEDDATA(obj)->data; + GC_ASSERT(data != NULL); + + rb_gc_obj_free_vm_weak_references(obj); + + size_t count = rbimpl_atomic_size_fetch_add(&global_objspace->tdata_deferred_free_count, 1, + RBIMPL_ATOMIC_RELAXED) + 1; + if (!*trigger && count >= TDATA_DEFERRED_FREE_THRESHOLD) { + *trigger = true; + } + + struct tdata_unsafe_free_chunk *chunk = objspace->tdata_unsafe_free_chunk; + if (chunk == NULL) { + chunk = objspace->tdata_unsafe_free_chunk = tdata_unsafe_free_chunk_alloc(); + } + if (type->flags & RUBY_TYPED_EMBEDDABLE) { + chunk->embed_xfree_bits |= (uint32_t)1 << chunk->count; + } + struct tdata_unsafe_free_entry *entry = &chunk->entries[chunk->count++]; + entry->dfree = type->function.dfree; + entry->data = data; + if (chunk->count == TDATA_UNSAFE_FREE_CHUNK_CAPA) { + gc_tdata_unsafe_free_publish(objspace); + } + + if (FL_TEST_RAW(obj, FL_FINALIZE)) { + /* The dfree is on the side list now, so this zombie carries none: it goes on the + * regular deferred list, where the owner runs its Ruby finalizer promptly and + * reclaims the slot, instead of waiting for the barrier. */ + rb_gc_impl_make_zombie(objspace, obj, 0, 0); + return false; + } + return true; +} + typedef int each_obj_callback(void *, void *, size_t, void *); typedef int each_page_callback(struct heap_page *, void *); @@ -4042,7 +4208,7 @@ finalize_list(rb_objspace_t *objspace, VALUE zombie) } static void -finalize_deferred_heap_pages(rb_objspace_t *objspace) +finalize_zombies(rb_objspace_t *objspace) { VALUE zombie; while ((zombie = RUBY_ATOMIC_VALUE_EXCHANGE(heap_pages_deferred_final, 0)) != 0) { @@ -4054,7 +4220,7 @@ static void finalize_deferred(rb_objspace_t *objspace) { rb_gc_set_pending_interrupt(); - finalize_deferred_heap_pages(objspace); + finalize_zombies(objspace); rb_gc_unset_pending_interrupt(); } @@ -4226,6 +4392,10 @@ rb_gc_impl_shutdown_call_finalizer(void *objspace_ptr) finalize_deferred(objspace); GC_ASSERT(heap_pages_deferred_final == 0); + /* Deferred non-thread-safe frees: their objects are long gone, so the object walk + * below will not reach them. Reap them here. */ + gc_tdata_unsafe_drain_objspaces(&objspace, 1); + /* Abort incremental marking and lazy sweeping to speed up shutdown. */ gc_abort(objspace); @@ -4258,7 +4428,7 @@ rb_gc_impl_shutdown_call_finalizer(void *objspace_ptr) gc_exit(objspace, gc_enter_event_finalizer, &lock_lev); - finalize_deferred_heap_pages(objspace); + finalize_zombies(objspace); st_free_table(finalizer_table); finalizer_table = 0; @@ -4679,11 +4849,182 @@ struct gc_sweep_context { int empty_slots; /* Hoisted out of the per-slot pinned-free assert: too expensive for the sweep loop * as an external call. */ - unsigned char check_pinned_free; + const bool check_pinned_free; + /* This is a parallel local sweep (multi-Ractor, not a global GC), so a non-thread-safe + * T_DATA dfree must be deferred to the global GC or the postponed job rather than run here. */ + const bool defer_thread_unsafe_local_sweep; + bool trigger_thread_unsafe_sweep_postponed_job; struct free_region *free_region; }; +/* NOTE: We must free the root fiber during postmortem collection, otherwise another Ractor + * can collect the fiber through a major GC while we're still tearing it down. Once fibers are + * THREAD_SAFE_FREE, we no longer need the root fiber condition as it will be guaranteed to be + * collected during this time. */ +static bool +gc_obj_defer_local_free_p(rb_objspace_t *objspace, VALUE obj) +{ + if (BUILTIN_TYPE(obj) != T_DATA) return false; + + const rb_data_type_t *type = RTYPEDDATA_TYPE(obj); + if (!rb_gc_data_type_deferred_free_p(type)) return false; + + if (RTYPEDDATA_GET_DATA(obj) == NULL) return false; + + if (type->flags & RUBY_TYPED_FREE_IMMEDIATELY) { + if (objspace->flags.during_postmortem) { + if (rb_fiber_current() == obj) { + return false; + } + } + return true; + } + else { + return false; + } +} + +static void gc_tdata_deferred_free_job(void *unused); +static void gc_tdata_deferred_free_pjob_ensure(void); +static unsigned int gc_during_gc_get(const rb_objspace_t *objspace); +static void gc_during_gc_set(rb_objspace_t *objspace, unsigned int v); +static void gc_global_snapshot_objspaces(void); + +static void +gc_tdata_deferred_free_pjob_ensure(void) +{ + if (global_objspace->tdata_deferred_free_pjob == POSTPONED_JOB_HANDLE_INVALID) { + global_objspace->tdata_deferred_free_pjob = + rb_postponed_job_preregister(0, gc_tdata_deferred_free_job, NULL); + if (global_objspace->tdata_deferred_free_pjob == POSTPONED_JOB_HANDLE_INVALID) { + rb_bug("Could not preregister postponed job for deferred T_DATA free"); + } + } +} + +/* A terminating Ractor's postmortem collection runs on an EC whose stack is already + * torn down: it never checks interrupts again, so a job triggered there is lost and no + * later sweep can rediscover the entries. Hand those to the main Ractor. */ +static void +gc_tdata_deferred_free_trigger(rb_objspace_t *objspace) +{ + if (objspace->flags.during_postmortem) { + rb_gc_trigger_postponed_job_on_main(global_objspace->tdata_deferred_free_pjob); + } + else { + rb_postponed_job_trigger(global_objspace->tdata_deferred_free_pjob); + } +} + +static void +gc_tdata_unsafe_free_entry(const struct tdata_unsafe_free_entry *entry, bool embed_xfree) +{ + entry->dfree(entry->data); + if (embed_xfree) { + xfree(entry->data); + } +} + +static void +tdata_unsafe_free_chunk_recycle(struct tdata_unsafe_free_chunk *chunk) +{ + if (global_objspace->tdata_unsafe_free_cache_len >= TDATA_UNSAFE_FREE_CACHE_MAX) { + free(chunk); + return; + } + tdata_unsafe_free_chunk_reset(chunk); + chunk->next = global_objspace->tdata_unsafe_free_cache; + rbimpl_atomic_ptr_store((volatile void **)&global_objspace->tdata_unsafe_free_cache, chunk, + RBIMPL_ATOMIC_RELEASE); + global_objspace->tdata_unsafe_free_cache_len++; +} + +static void +gc_tdata_unsafe_drain_chunk(struct tdata_unsafe_free_chunk *chunk) +{ + for (unsigned int i = 0; i < chunk->count; i++) { + gc_tdata_unsafe_free_entry(&chunk->entries[i], + (chunk->embed_xfree_bits >> i) & 1); + } + tdata_unsafe_free_chunk_recycle(chunk); +} + +/* Run every pending deferred free: the published chunks (which belong to no objspace) + * plus the given objspaces' partial chunks. The caller must have stopped the world -- + * VM barrier held, or a single Ractor left in the process -- and must pass every live + * objspace, since the pending count is zeroed here. (Shutdown is the one exception: + * nothing reads the count afterwards.) */ +static void +gc_tdata_unsafe_drain_objspaces(rb_objspace_t **objspaces, size_t n) +{ + struct tdata_unsafe_free_chunk *chunk = + rbimpl_atomic_ptr_exchange((void **)&global_objspace->tdata_unsafe_free_published, NULL, + RBIMPL_ATOMIC_ACQ_REL); + while (chunk) { + struct tdata_unsafe_free_chunk *next = chunk->next; + gc_tdata_unsafe_drain_chunk(chunk); + chunk = next; + } + + for (size_t i = 0; i < n; i++) { + rb_objspace_t *os = objspaces[i]; + struct tdata_unsafe_free_chunk *partial = os->tdata_unsafe_free_chunk; + if (partial) { + os->tdata_unsafe_free_chunk = NULL; + gc_tdata_unsafe_drain_chunk(partial); + } + } + + rbimpl_atomic_size_exchange(&global_objspace->tdata_deferred_free_count, 0, + RBIMPL_ATOMIC_RELAXED); +} + +/* Stop the world and run the dfree function for all deferred T_DATAs. */ +static void +gc_tdata_unsafe_drain(void) +{ + unsigned int lev = RB_GC_VM_LOCK(); + + if (tdata_deferred_free_count_load() == 0) { + RB_GC_VM_UNLOCK(lev); + return; + } + + rb_gc_vm_barrier(); + + gc_global_snapshot_objspaces(); + + /* Set during_gc=TRUE and init vm_context for the CURRENT objspace only. + * The no-alloc guard checks only the allocating (=current) objspace's during_gc, + * and rb_gc_get_ec() reads only the current objspace's vm_context.ec. */ + rb_objspace_t *objspace = rb_gc_get_objspace(); + unsigned int saved_during_gc = gc_during_gc_get(objspace); + dont_gc_on(); + rb_gc_initialize_vm_context(&objspace->vm_context); + gc_during_gc_set(objspace, TRUE); + + gc_tdata_unsafe_drain_objspaces(global_objspace->global_gc.objspaces, + global_objspace->global_gc.n_objspaces); + + gc_during_gc_set(objspace, saved_during_gc); + dont_gc_off(); + + RB_GC_VM_UNLOCK(lev); +} + +static void +gc_tdata_deferred_free_job(void *unused) +{ + (void)unused; + + size_t count = tdata_deferred_free_count_load(); + if (count == 0) return; + if (count < TDATA_DEFERRED_FREE_THRESHOLD && !rb_gc_single_objspace_p()) return; + + gc_tdata_unsafe_drain(); +} + static inline void gc_sweep_register_free_slot(rb_objspace_t *objspace, struct heap_page *page, struct gc_sweep_context *ctx, uintptr_t p, short slot_size) { @@ -4786,6 +5127,21 @@ gc_sweep_plane(rb_objspace_t *objspace, rb_heap_t *heap, uintptr_t p, bits_t bit else { gc_report(2, objspace, "page_sweep: free %p\n", (void *)p); + if (RB_UNLIKELY(ctx->defer_thread_unsafe_local_sweep && gc_obj_defer_local_free_p(objspace, vp))) { + /* Defer the dfree instead of running it here: it needs the world + * stopped, which a parallel local sweep cannot give it. The slot is reusable + * right away unless we had to create a zombie. */ + if (gc_defer_thread_unsafe_free(objspace, vp, + &ctx->trigger_thread_unsafe_sweep_postponed_job)) { + (void)VALGRIND_MAKE_MEM_UNDEFINED((void*)p, slot_size); + gc_sweep_register_free_slot(objspace, sweep_page, ctx, p, slot_size); + ctx->freed_slots++; + } + else { + ctx->final_slots++; + } + break; + } rb_gc_obj_free_vm_weak_references(vp); if (gc_obj_free(objspace, vp)) { (void)VALGRIND_MAKE_MEM_UNDEFINED((void*)p, slot_size); @@ -5239,6 +5595,13 @@ gc_sweep_finish(rb_objspace_t *objspace) gc_malloc_counters_snapshot_free_at_last_gc(objspace, &objspace->malloc_counters.oldcounters); #endif + /* Leftovers from an earlier multi-Ractor phase: no later sweep can rediscover them + * (their slots are gone), and with one Ractor left the drain's barrier has nothing + * to wait for. */ + if (tdata_deferred_free_count_load() > 0 && rb_gc_single_objspace_p()) { + gc_tdata_deferred_free_trigger(objspace); + } + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_END_SWEEP); gc_mode_transition(objspace, gc_mode_none); } @@ -5262,7 +5625,11 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) * ran the pinned walk. The current world state would misfire: a single-world * cycle leaves dead shareable objects unmarked and its sweep can straddle the switch * to multi-objspace. A global GC's exact mark does not pin, so it is excluded. */ - const unsigned char check_pinned_free = objspace->last_cycle_pinned; + const bool check_pinned_free = objspace->last_cycle_pinned; + + const bool defer_thread_unsafe_local_sweep = + !rb_gc_single_objspace_p() && !objspace->flags.during_global_gc; + bool trigger_thread_unsafe_sweep_postponed_job = false; do { RUBY_DEBUG_LOG("sweep_page:%p", (void *)sweep_page); @@ -5273,9 +5640,12 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) .freed_slots = 0, .empty_slots = 0, .check_pinned_free = check_pinned_free, + .defer_thread_unsafe_local_sweep = defer_thread_unsafe_local_sweep, + .trigger_thread_unsafe_sweep_postponed_job = trigger_thread_unsafe_sweep_postponed_job, }; gc_sweep_page(objspace, heap, &ctx); int free_slots = ctx.freed_slots + ctx.empty_slots; + trigger_thread_unsafe_sweep_postponed_job = ctx.trigger_thread_unsafe_sweep_postponed_job; RUBY_DTRACE_GC_HOOK(SWEEP_PAGE, ctx.page->slot_size, ctx.final_slots, ctx.freed_slots, ctx.empty_slots); @@ -5323,6 +5693,11 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) } } while ((sweep_page = heap->sweeping_page)); + if (trigger_thread_unsafe_sweep_postponed_job) { + gc_report(2, objspace, "thread-unsafe sweep postponed job triggered\n"); + gc_tdata_deferred_free_trigger(objspace); + } + if (!heap->sweeping_page) { objspace->sweeping_heap_count--; GC_ASSERT(objspace->sweeping_heap_count >= 0); @@ -8173,7 +8548,10 @@ finalize_deferred_dfree_only(rb_objspace_t *objspace) zombie = next; } if (dfree_only) finalize_list(objspace, dfree_only); - return dfree_only != 0; + bool did = dfree_only != 0; + + gc_tdata_unsafe_free_publish(objspace); + return did; } void @@ -9383,6 +9761,12 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a if (new_limit < SHAREABLE_OBJECTS_LIMIT_MIN) new_limit = SHAREABLE_OBJECTS_LIMIT_MIN; objspace->shareable_objects_limit = new_limit; } + + /* Deferred non-thread-safe frees: the world is already stopped here, so reap them + * without a second barrier. Uses the driver's snapshot rather than taking its own, + * which step 10 below still walks. */ + gc_tdata_unsafe_drain_objspaces(global_objspace->global_gc.objspaces, + global_objspace->global_gc.n_objspaces); driver->profile.count++; /* step 10 */ @@ -9594,6 +9978,12 @@ objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src) rb_postponed_job_trigger(dst->finalize_deferred_pjob); } } + if (src->tdata_unsafe_free_chunk) { + gc_tdata_unsafe_free_publish(src); + } + if (tdata_deferred_free_count_load() >= TDATA_DEFERRED_FREE_THRESHOLD) { + gc_tdata_deferred_free_trigger(dst); + } /* Counters inherited by dst. */ dst->rgengc.old_objects += src->rgengc.old_objects; @@ -9690,6 +10080,11 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i } gc_finalize_deferred(objspace); + /* An explicit GC.start is expected to reclaim immediately, so run the deferred non-thread-safe + * frees synchronously instead of leaving them to gc_sweep_finish's postponed job. */ + if (tdata_deferred_free_count_load() > 0 && rb_gc_single_objspace_p()) { + gc_tdata_unsafe_drain(); + } gc_config_full_mark_set(full_marking_p); } @@ -12697,6 +13092,8 @@ rb_gc_impl_objspace_init(void *objspace_ptr) rb_bug("Could not preregister postponed job for GC"); } + gc_tdata_deferred_free_pjob_ensure(); + /* A standard RVALUE (RBasic + embedded VALUEs + debug overhead) must fit * in at least one pool. In debug builds RVALUE_OVERHEAD can push this * beyond the 48-byte pool into the 64-byte pool, which is fine. */ diff --git a/gc/gc.h b/gc/gc.h index d77ab4ab1296ce..3360744bb02ab4 100644 --- a/gc/gc.h +++ b/gc/gc.h @@ -98,6 +98,7 @@ MODULAR_GC_FN void *rb_gc_get_objspace(void); MODULAR_GC_FN void rb_gc_run_obj_finalizer(VALUE objid, long count, VALUE (*callback)(long i, void *data), void *data); MODULAR_GC_FN void rb_gc_set_pending_interrupt(void); MODULAR_GC_FN void rb_gc_trigger_finalize_deferred(void *objspace, rb_postponed_job_handle_t pjob); +MODULAR_GC_FN void rb_gc_trigger_postponed_job_on_main(rb_postponed_job_handle_t pjob); MODULAR_GC_FN void rb_gc_unset_pending_interrupt(void); MODULAR_GC_FN void rb_gc_obj_free_vm_weak_references(VALUE obj); MODULAR_GC_FN bool rb_gc_obj_free(void *objspace, VALUE obj); @@ -121,6 +122,17 @@ MODULAR_GC_FN void rb_gc_rp(VALUE); MODULAR_GC_FN void rb_gc_handle_weak_references(VALUE obj); MODULAR_GC_FN bool rb_gc_obj_needs_cleanup_p(VALUE obj); +/* True when a dead T_DATA of this type cannot have its dfree run during a parallel + * local sweep. Such a type is never embedded, which lets the sweep reclaim the slot + * immediately. */ +static inline bool +rb_gc_data_type_deferred_free_p(const rb_data_type_t *type) +{ + void (*dfree)(void *) = type->function.dfree; + if (!dfree || dfree == RUBY_DEFAULT_FREE) return false; + return !(type->flags & RUBY_TYPED_THREAD_SAFE_FREE); +} + void rb_gc_initialize_vm_context(struct rb_gc_vm_context *context); #if USE_MODULAR_GC MODULAR_GC_FN bool rb_gc_event_hook_required_p(rb_event_flag_t event); diff --git a/include/ruby/internal/core/rtypeddata.h b/include/ruby/internal/core/rtypeddata.h index 8d1374dcce2769..a608384548748b 100644 --- a/include/ruby/internal/core/rtypeddata.h +++ b/include/ruby/internal/core/rtypeddata.h @@ -174,7 +174,11 @@ rbimpl_typeddata_flags { * * Pointers into the associated C struct MUST NOT be used after the ruby * object is not longer on the stack, as they become invalid when GC - * compaction occurs + * compaction occurs. + * + * This flag has no effect unless: + * * dfree is NULL or RUBY_TYPED_DEFAULT_FREE. + * * The RUBY_TYPED_THREAD_SAFE_FREE is set. */ RUBY_TYPED_EMBEDDABLE = 2, diff --git a/ractor.c b/ractor.c index b38772c4e8d086..857a5fe1f8cf14 100644 --- a/ractor.c +++ b/ractor.c @@ -576,6 +576,7 @@ cancel_single_ractor_mode(void) rb_yjit_invalidate_single_ractor(); rb_zjit_invalidate_single_ractor(); + ASSERT_vm_unlocking(); rb_funcall(rb_cRactor, rb_intern("_activated"), 0); } diff --git a/test/-ext-/gc/test_tdata_non_thread_safe_free.rb b/test/-ext-/gc/test_tdata_non_thread_safe_free.rb new file mode 100644 index 00000000000000..4d1f2326504c7f --- /dev/null +++ b/test/-ext-/gc/test_tdata_non_thread_safe_free.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: false +require 'test/unit' +require '-test-/gc/tdata_non_thread_safe_free' + +class TestTDataNonThreadSafeFree < Test::Unit::TestCase + def test_non_thread_safe_dfree_is_not_called_concurrently + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + RACTORS = 4 + ITERS = 10 + BATCH = 10_000 + + ractors = RACTORS.times.map do + Ractor.new do + ITERS.times { Bug::TDataNonThreadSafeFree.make(BATCH) } + :done + end + end + ractors.each(&:value) + + max = Bug::TDataNonThreadSafeFree.max_concurrent_free + total = Bug::TDataNonThreadSafeFree.total_frees + + assert_operator total, :>, 0, "expected tdatas to have been freed (postponed job)" + assert_operator max, :==, 1, + "non-thread-safe dfree ran concurrently (BUG!): observed #{max} simultaneous " \ + "frees across #{total} total frees; Ractor-local GC must not invoke a dfree " \ + "for types lacking RUBY_TYPED_THREAD_SAFE_FREE" + RUBY + end + + def test_deferred_free_postponed_job + # Create enough non-thread-safe T_DATA across multiple Ractors to exceed the + # threshold, triggering the postponed job that sweeps them under the VM barrier + # without a full global GC. + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + + RACTORS = 4 + BATCH = 10_000 + ITERS = 10 + + ractors = RACTORS.times.map do + Ractor.new do + ITERS.times { Bug::TDataNonThreadSafeFree.make(BATCH) } + :done + end + end + ractors.each(&:value) + assert_operator Bug::TDataNonThreadSafeFree.total_frees, :>, 0, + "postponed job should have fired and freed deferred tdatas" + + max = Bug::TDataNonThreadSafeFree.max_concurrent_free + assert_operator max, :==, 1, + "non-thread-safe dfree ran concurrently (BUG!): observed #{max} simultaneous frees" + RUBY + end + + def test_single_ractor_freed_by_major_gc + # Not assert_ractor: its preamble creates a Ractor to trigger the experimental + # warning, which leaves single-Ractor mode for the rest of the process. + assert_separately(["-r-test-/gc/tdata_non_thread_safe_free"], <<~'RUBY') + Bug::TDataNonThreadSafeFree.reset + + BATCH = 50_000 + + Bug::TDataNonThreadSafeFree.make(BATCH) + GC.start + + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :>, 0, + "expected a single-Ractor major GC to free non-thread-safe T_DATA" + RUBY + end + + def test_multi_ractor_under_threshold_no_postponed_job + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + + r = Ractor.new { receive } + + BATCH = 10_000 + before = GC.stat(:count) + Bug::TDataNonThreadSafeFree.make(BATCH) + after = GC.stat(:count) + + if before == after + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :==, 0, + "If didn't hit postponed job threshold or trigger GC, shouldn't have freed any" + end + r.send(nil); r.join + RUBY + end + + def test_multi_ractor_to_single_ractor_major_should_collect + # Not assert_ractor: this test has to get back to single-Ractor mode, and the + # preamble's unjoined Ractor keeps it out of it. + assert_separately(["-r-test-/gc/tdata_non_thread_safe_free", "-W0"], <<~'RUBY') + Bug::TDataNonThreadSafeFree.reset + + r = Ractor.new { receive } + + BATCH = 10_000 + before = GC.stat(:count) + Bug::TDataNonThreadSafeFree.make(BATCH) + after = GC.stat(:count) + + if before == after + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :==, 0, + "If didn't hit postponed job threshold or trigger global GC, shouldn't have freed any" + end + + r.send(nil); r.value + GC.start # single-ractor major GC + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :>, 0, + "expected a single-Ractor major GC to free non-thread-safe T_DATA" + RUBY + end + + def test_multi_ractor_global_gc_should_collect + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + + r = Ractor.new { receive } + + BATCH = 10_000 + before = GC.stat(:count) + Bug::TDataNonThreadSafeFree.make(BATCH) + after = GC.stat(:count) + + if before == after + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :==, 0, + "If didn't hit postponed job threshold or trigger GC, shouldn't have freed any" + end + + GC.start # global GC + total = Bug::TDataNonThreadSafeFree.total_frees + assert_operator total, :>, 0, + "expected a multi-ractor global GC to free non-thread-safe T_DATA (under barrier)" + r.send(nil); r.join + RUBY + end + + def test_embeddable_non_thread_safe_free_is_not_embedded + # A deferred free outlives its slot, so the payload must not live in the slot. + # The control type differs only by RUBY_TYPED_THREAD_SAFE_FREE, proving that the + # payload size is not what denied embedding. + refute Bug::TDataNonThreadSafeFree.embeddable_embedded?, + "an embeddable T_DATA without RUBY_TYPED_THREAD_SAFE_FREE must not be embedded" + assert Bug::TDataNonThreadSafeFree.thread_safe_embeddable_embedded?, + "an embeddable T_DATA with RUBY_TYPED_THREAD_SAFE_FREE should still be embedded" + end + + def test_embeddable_freed_by_drain + # An embeddable type that was not embedded: the GC xfrees the buffer after the + # dfree, so the deferred entry has to remember that the type is embeddable. + assert_separately(["-r-test-/gc/tdata_non_thread_safe_free", "-W0"], <<~'RUBY') + Bug::TDataNonThreadSafeFree.reset + + r = Ractor.new { receive } + + BATCH = 30_000 + Bug::TDataNonThreadSafeFree.make_embeddable(BATCH) + + r.send(nil); r.value + GC.start + assert_operator Bug::TDataNonThreadSafeFree.embeddable_frees, :>, 0, + "expected a single-Ractor major GC to drain the deferred frees" + RUBY + end + + def test_ruby_finalizer_and_dfree_both_run + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + + r = Ractor.new { receive } + + N = 100 + finalized = [] + # Built through a lambda so the finalizer's binding cannot reach the object. + make_finalizer = ->(acc) { proc { acc << 1 } } + N.times do + obj = Bug::TDataNonThreadSafeFree.new + ObjectSpace.define_finalizer(obj, make_finalizer.call(finalized)) + end + + GC.start + r.send(nil); r.value + GC.start + + assert_operator finalized.size, :>, 0, + "a Ruby-level finalizer must still run on a deferred non-thread-safe T_DATA" + assert_equal finalized.size, Bug::TDataNonThreadSafeFree.total_frees, + "an object got its Ruby finalizer but not its deferred dfree, or vice versa" + RUBY + end + + def test_mixed_variants_never_free_concurrently + assert_separately(["-r-test-/gc/tdata_non_thread_safe_free", "-W0"], <<~'RUBY') + Bug::TDataNonThreadSafeFree.reset + + RACTORS = 4 + ITERS = 10 + + assert_operator 4 * 10 * 2_000, :>, 2**15 + + ractors = RACTORS.times.map do |n| + Ractor.new(n) do |kind| + ITERS.times do + case kind % 2 + when 0 then Bug::TDataNonThreadSafeFree.make(2_000) + else Bug::TDataNonThreadSafeFree.make_embeddable(2_000) + end + end + :done + end + end + ractors.each(&:value) + + total = Bug::TDataNonThreadSafeFree.total_frees + + Bug::TDataNonThreadSafeFree.embeddable_frees + assert_operator total, :>, 0, "expected some tdatas to have been freed" + + max = Bug::TDataNonThreadSafeFree.max_concurrent_free + assert_operator max, :==, 1, + "non-thread-safe dfree ran concurrently (BUG!): observed #{max} simultaneous " \ + "frees across #{total} total" + RUBY + end + + # NullPayload wraps a NULL pointer. rb_data_free runs no dfree at all for a NULL payload, + # so the deferred path must not run one either. + def test_null_payload_is_not_deferred + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + keep = Ractor.new { receive } + + Bug::TDataNonThreadSafeFree.make_null_payload(10_000) + Bug::TDataNonThreadSafeFree.make_filled_payload(10_000) + GC.start + + keep.send(nil); keep.value + GC.start + + assert_equal 0, Bug::TDataNonThreadSafeFree.null_payload_null_frees, + "dfree ran for a NULL payload" + assert_operator Bug::TDataNonThreadSafeFree.null_payload_frees, :>, 0, + "no dfree ran at all" + RUBY + end + + def test_null_payload_with_finalizer_still_runs_it + assert_ractor(<<~'RUBY', require: "-test-/gc/tdata_non_thread_safe_free") + Bug::TDataNonThreadSafeFree.reset + keep = Ractor.new { receive } + + klass = Bug::TDataNonThreadSafeFree::NullPayload + finalized = [] + make_finalizer = ->(acc) { proc { acc << 1 } } + 20_000.times do + ObjectSpace.define_finalizer(klass.allocate, make_finalizer.call(finalized)) + end + + GC.start + keep.send(nil); keep.value + GC.start + + assert_operator finalized.size, :>, 0, + "a Ruby finalizer on a NULL-payload deferred-free T_DATA was dropped" + assert_equal 0, Bug::TDataNonThreadSafeFree.null_payload_null_frees, + "dfree ran for a NULL payload" + RUBY + end + + # A dfree is allowed to free a dynamically allocated rb_data_type_t -- Bug::TypedData + # .dynamic_type owns its type that way. The deferred entry must therefore resolve + # everything it needs from the type before running the dfree; reading type->flags + # afterwards is a use-after-free. + def test_dfree_may_free_its_own_data_type + assert_ractor(<<~'RUBY', require: "-test-/typeddata") + keep = Ractor.new { receive } + + 20_000.times { Bug::TypedData.dynamic_type } + GC.start + + keep.send(nil); keep.value + GC.start + assert true + RUBY + end +end From 51b39ee205801e8c2d91228fce879038c2f42d09 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 18 Sep 2026 10:26:41 +0900 Subject: [PATCH 05/22] [ruby/rubygems] Validate the version field in Gem::Installer#verify_spec Gem::Specification#to_ruby interpolates the version into the `# stub:` comment without escaping, and #ensure_loadable_spec evals that output, so a line break in the version ends the comment and the rest runs as Ruby before the version itself is rejected. A version loaded from gem metadata with Psych goes through Gem::Version#yaml_initialize and skips the checks in #initialize. Require the pattern Gem::Version accepts, without the surrounding whitespace it strips. https://github.com/ruby/rubygems/commit/78aa8f57b1 Co-Authored-By: Claude Opus 5 --- lib/rubygems/installer.rb | 4 +++ test/rubygems/test_gem_installer.rb | 40 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/rubygems/installer.rb b/lib/rubygems/installer.rb index b8d95df7ae7b2e..79f4d3406b2fd1 100644 --- a/lib/rubygems/installer.rb +++ b/lib/rubygems/installer.rb @@ -719,6 +719,10 @@ def verify_spec raise Gem::InstallError, "#{spec} has an invalid name" end + unless /\A#{Gem::Version::VERSION_PATTERN}\z/.match?(spec.version.to_s) + raise Gem::InstallError, "#{spec} has an invalid version" + end + if spec.raw_require_paths.any? {|path| path =~ /\R/ } raise Gem::InstallError, "#{spec} has an invalid require_paths" end diff --git a/test/rubygems/test_gem_installer.rb b/test/rubygems/test_gem_installer.rb index 162f8dc347a902..f7402874b145bc 100644 --- a/test/rubygems/test_gem_installer.rb +++ b/test/rubygems/test_gem_installer.rb @@ -2368,6 +2368,46 @@ def spec.validate(*args); end refute defined?(::Object::FROM_EVAL) end + # Psych restores a version from gem metadata through + # Gem::Version#yaml_initialize, which skips the checks in #initialize. + def test_pre_install_checks_malicious_version_before_eval + spec = util_spec "malicious", "1" + def spec.validate(*args); end + version = Gem::Version.allocate + version.yaml_initialize nil, "version" => "1\n::Object.const_set(:FROM_EVAL, true)#" + spec.version = version + + installer = Gem::Installer.for_spec spec + installer.gem_home = @gemhome + + use_ui @ui do + e = assert_raise Gem::InstallError do + installer.pre_install_checks + end + assert_equal "# has an invalid version", e.message + end + refute defined?(::Object::FROM_EVAL) + end + + def test_pre_install_checks_accepts_real_versions + %w[1 1.0.0 1.0.0.a 1.0.0-rc.1 0.1.0.pre.20260918].each do |version| + spec = util_spec "a", version + + util_build_gem spec + + installer = Gem::Installer.at spec.cache_file, + install_dir: @gemhome, + user_install: false, + force: true + + use_ui @ui do + assert_equal spec, installer.install, version + end + + assert_path_exist File.join(@gemhome, "gems", spec.full_name), version + end + end + def test_pre_install_checks_malicious_require_paths_before_eval spec = util_spec "malicious", "1" def spec.full_name # so the spec is buildable From b14e047fe6390d2d99574d46bfdc8de925660592 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 18 Sep 2026 10:42:09 +0900 Subject: [PATCH 06/22] [ruby/rubygems] Describe --major as preferring the latest major version GemVersionPromoter sorts every candidate newest first under --major, and --strict does not cap it at any major, so "next major version" never matched what it does. https://github.com/ruby/rubygems/issues/8090 https://github.com/ruby/rubygems/commit/8db0a06d37 Co-Authored-By: Claude Opus 5 --- lib/bundler/cli.rb | 6 +++--- lib/bundler/man/bundle-lock.1 | 2 +- lib/bundler/man/bundle-lock.1.ronn | 2 +- lib/bundler/man/bundle-outdated.1 | 2 +- lib/bundler/man/bundle-outdated.1.ronn | 2 +- lib/bundler/man/bundle-update.1 | 4 ++-- lib/bundler/man/bundle-update.1.ronn | 4 ++-- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/bundler/cli.rb b/lib/bundler/cli.rb index 15b92f3e18d2f6..273f9a8ad6841e 100644 --- a/lib/bundler/cli.rb +++ b/lib/bundler/cli.rb @@ -322,7 +322,7 @@ def install method_option "bundler", type: :string, lazy_default: ">= #{Bundler::VERSION}", banner: "Update the locked version of bundler" method_option "patch", type: :boolean, banner: "Prefer updating only to next patch version" method_option "minor", type: :boolean, banner: "Prefer updating only to next minor version" - method_option "major", type: :boolean, banner: "Prefer updating to next major version (default)" + method_option "major", type: :boolean, banner: "Prefer updating to latest major version (default)" method_option "pre", type: :boolean, banner: "Always choose the highest allowed version when updating gems, regardless of prerelease status" method_option "strict", type: :boolean, banner: "Do not allow any gem to be updated past latest --patch | --minor | --major" method_option "conservative", type: :boolean, banner: "Use bundle install conservative update behavior and do not allow shared dependencies to be updated." @@ -436,7 +436,7 @@ def add(*gems) method_option "filter-strict", type: :boolean, aliases: "--strict", banner: "Only list newer versions allowed by your Gemfile requirements" method_option "update-strict", type: :boolean, banner: "Strict conservative resolution, do not allow any gem to be updated past latest --patch | --minor | --major" method_option "minor", type: :boolean, banner: "Prefer updating only to next minor version" - method_option "major", type: :boolean, banner: "Prefer updating to next major version (default)" + method_option "major", type: :boolean, banner: "Prefer updating to latest major version (default)" method_option "patch", type: :boolean, banner: "Prefer updating only to next patch version" method_option "filter-major", type: :boolean, banner: "Only list major newer versions" method_option "filter-minor", type: :boolean, banner: "Only list minor newer versions" @@ -643,7 +643,7 @@ def inject(*) method_option "normalize-platforms", type: :boolean, default: false, banner: "Normalize lockfile platforms" method_option "patch", type: :boolean, banner: "If updating, prefer updating only to next patch version" method_option "minor", type: :boolean, banner: "If updating, prefer updating only to next minor version" - method_option "major", type: :boolean, banner: "If updating, prefer updating to next major version (default)" + method_option "major", type: :boolean, banner: "If updating, prefer updating to latest major version (default)" method_option "pre", type: :boolean, banner: "If updating, always choose the highest allowed version, regardless of prerelease status" method_option "strict", type: :boolean, banner: "If updating, do not allow any gem to be updated past latest --patch | --minor | --major" method_option "conservative", type: :boolean, banner: "If updating, use bundle install conservative update behavior and do not allow shared dependencies to be updated" diff --git a/lib/bundler/man/bundle-lock.1 b/lib/bundler/man/bundle-lock.1 index 3f0f623bce963b..11ded1088f72c4 100644 --- a/lib/bundler/man/bundle-lock.1 +++ b/lib/bundler/man/bundle-lock.1 @@ -49,7 +49,7 @@ If updating, prefer updating only to next patch version\. If updating, prefer updating only to next minor version\. .TP \fB\-\-major\fR -If updating, prefer updating to next major version (default)\. +If updating, prefer updating to latest major version (default)\. .TP \fB\-\-pre\fR If updating, always choose the highest allowed version, regardless of prerelease status\. diff --git a/lib/bundler/man/bundle-lock.1.ronn b/lib/bundler/man/bundle-lock.1.ronn index ecf477f475f6c6..ad33ed552c66a9 100644 --- a/lib/bundler/man/bundle-lock.1.ronn +++ b/lib/bundler/man/bundle-lock.1.ronn @@ -75,7 +75,7 @@ Lock the gems specified in Gemfile. If updating, prefer updating only to next minor version. * `--major`: - If updating, prefer updating to next major version (default). + If updating, prefer updating to latest major version (default). * `--pre`: If updating, always choose the highest allowed version, regardless of prerelease status. diff --git a/lib/bundler/man/bundle-outdated.1 b/lib/bundler/man/bundle-outdated.1 index d884a1ff9bcae4..ae0ee8b0c78a64 100644 --- a/lib/bundler/man/bundle-outdated.1 +++ b/lib/bundler/man/bundle-outdated.1 @@ -37,7 +37,7 @@ List gems organized by groups\. Prefer updating only to next minor version\. .TP \fB\-\-major\fR -Prefer updating to next major version (default)\. +Prefer updating to latest major version (default)\. .TP \fB\-\-patch\fR Prefer updating only to next patch version\. diff --git a/lib/bundler/man/bundle-outdated.1.ronn b/lib/bundler/man/bundle-outdated.1.ronn index 468b84d057d412..570ff08fd42d1f 100644 --- a/lib/bundler/man/bundle-outdated.1.ronn +++ b/lib/bundler/man/bundle-outdated.1.ronn @@ -55,7 +55,7 @@ are up to date, Bundler will exit with a status of 0. Otherwise, it will exit 1. Prefer updating only to next minor version. * `--major`: - Prefer updating to next major version (default). + Prefer updating to latest major version (default). * `--patch`: Prefer updating only to next patch version. diff --git a/lib/bundler/man/bundle-update.1 b/lib/bundler/man/bundle-update.1 index 2ff8401be300f7..eddab20f868ea2 100644 --- a/lib/bundler/man/bundle-update.1 +++ b/lib/bundler/man/bundle-update.1 @@ -54,7 +54,7 @@ Prefer updating only to next patch version\. Prefer updating only to next minor version\. .TP \fB\-\-major\fR -Prefer updating to next major version (default)\. +Prefer updating to latest major version (default)\. .TP \fB\-\-pre\fR Always choose the highest allowed version, regardless of prerelease status\. @@ -175,7 +175,7 @@ Prefer updating only to next patch version\. Prefer updating only to next minor version\. .TP \fB\-\-major\fR -Prefer updating to next major version (default)\. +Prefer updating to latest major version (default)\. .TP \fB\-\-strict\fR Do not allow any gem to be updated past latest \fB\-\-patch\fR | \fB\-\-minor\fR | \fB\-\-major\fR\. diff --git a/lib/bundler/man/bundle-update.1.ronn b/lib/bundler/man/bundle-update.1.ronn index bb1913622c7153..f02d264328e791 100644 --- a/lib/bundler/man/bundle-update.1.ronn +++ b/lib/bundler/man/bundle-update.1.ronn @@ -83,7 +83,7 @@ gem. Prefer updating only to next minor version. * `--major`: - Prefer updating to next major version (default). + Prefer updating to latest major version (default). * `--pre`: Always choose the highest allowed version, regardless of prerelease status. @@ -238,7 +238,7 @@ versions are resolved. One of the following options can be used: `--patch`, Prefer updating only to next minor version. * `--major`: - Prefer updating to next major version (default). + Prefer updating to latest major version (default). * `--strict`: Do not allow any gem to be updated past latest `--patch` | `--minor` | `--major`. From 44182010311fef524fd6de61fb0b72e5750bb31b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:25:40 +0900 Subject: [PATCH 07/22] [ruby/rubygems] Drop feature detection for Ruby core methods older than 3.2 Symbol#name (3.0), Exception#detailed_message (3.2), Module#ruby2_keywords (2.7) and URI::Generic#hostname (1.9.3) exist on every supported Ruby, so the fallbacks were dead code. https://github.com/ruby/rubygems/commit/ee203a9319 Co-Authored-By: Claude Fable 5.1 --- lib/bundler/settings.rb | 36 ++++++------------- lib/rubygems/command_manager.rb | 6 +--- lib/rubygems/deprecate.rb | 4 +-- lib/rubygems/request/connection_pools.rb | 3 +- lib/rubygems/safe_marshal/visitors/to_ruby.rb | 30 +++++----------- test/rubygems/helper.rb | 2 +- test/rubygems/test_gem_command_manager.rb | 6 +--- 7 files changed, 24 insertions(+), 63 deletions(-) diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index 75f9feaa27eae8..0502a074a7bdd2 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -896,32 +896,16 @@ def self.normalize_uri(uri) "#{prefix}#{uri}#{suffix}" end - # This is a hot method, so avoid respond_to? checks on every invocation - if :read.respond_to?(:name) - def self.key_to_s(key) - case key - when String - key - when Symbol - key.name - when Gem::URI::HTTP - key.to_s - else - raise ArgumentError, "Invalid key: #{key.inspect}" - end - end - else - def self.key_to_s(key) - case key - when String - key - when Symbol - key.to_s - when Gem::URI::HTTP - key.to_s - else - raise ArgumentError, "Invalid key: #{key.inspect}" - end + def self.key_to_s(key) + case key + when String + key + when Symbol + key.name + when Gem::URI::HTTP + key.to_s + else + raise ArgumentError, "Invalid key: #{key.inspect}" end end end diff --git a/lib/rubygems/command_manager.rb b/lib/rubygems/command_manager.rb index 76b2fba83550ca..7d0d42b2ad17a4 100644 --- a/lib/rubygems/command_manager.rb +++ b/lib/rubygems/command_manager.rb @@ -150,11 +150,7 @@ def command_names def run(args, build_args = nil) process_args(args, build_args) rescue StandardError, Gem::Timeout::Error => ex - if ex.respond_to?(:detailed_message) - msg = ex.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 } - else - msg = ex.message - end + msg = ex.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 } alert_error clean_text("While executing gem ... (#{ex.class})\n #{msg}") ui.backtrace ex diff --git a/lib/rubygems/deprecate.rb b/lib/rubygems/deprecate.rb index 8402b839334fb3..729c4a68c18cba 100644 --- a/lib/rubygems/deprecate.rb +++ b/lib/rubygems/deprecate.rb @@ -116,7 +116,7 @@ def deprecate(name, repl, year, month) warn "#{msg.join}." unless Gem::Deprecate.skip send old, *args, &block end - ruby2_keywords name if respond_to?(:ruby2_keywords, true) + ruby2_keywords name end end @@ -143,7 +143,7 @@ def rubygems_deprecate(name, replacement = :none, version = nil) warn "#{msg.join}." unless Gem::Deprecate.skip send old, *args, &block end - ruby2_keywords name if respond_to?(:ruby2_keywords, true) + ruby2_keywords name end end diff --git a/lib/rubygems/request/connection_pools.rb b/lib/rubygems/request/connection_pools.rb index b2f952dd7ad9b4..c5f5531570e474 100644 --- a/lib/rubygems/request/connection_pools.rb +++ b/lib/rubygems/request/connection_pools.rb @@ -83,9 +83,8 @@ def net_http_args(uri, proxy_uri) no_proxy = get_no_proxy_from_env if proxy_uri && !no_proxy?(hostname, no_proxy) - proxy_hostname = proxy_uri.respond_to?(:hostname) ? proxy_uri.hostname : proxy_uri.host net_http_args + [ - proxy_hostname, + proxy_uri.hostname, proxy_uri.port, Gem::UriFormatter.new(proxy_uri.user).unescape, Gem::UriFormatter.new(proxy_uri.password).unescape, diff --git a/lib/rubygems/safe_marshal/visitors/to_ruby.rb b/lib/rubygems/safe_marshal/visitors/to_ruby.rb index 3743b2972a9142..34dfffd10d749b 100644 --- a/lib/rubygems/safe_marshal/visitors/to_ruby.rb +++ b/lib/rubygems/safe_marshal/visitors/to_ruby.rb @@ -331,28 +331,14 @@ def visit_symbol_type(element) end end - # This is a hot method, so avoid respond_to? checks on every invocation - if :read.respond_to?(:name) - def resolve_symbol_name(element) - case element - when Elements::Symbol - element.name - when Elements::SymbolLink - visit_Gem_SafeMarshal_Elements_SymbolLink(element).name - else - raise FormatError, "Expected symbol or symbol link, got #{element.inspect} @ #{formatted_stack.join(".")}" - end - end - else - def resolve_symbol_name(element) - case element - when Elements::Symbol - element.name - when Elements::SymbolLink - visit_Gem_SafeMarshal_Elements_SymbolLink(element).to_s - else - raise FormatError, "Expected symbol or symbol link, got #{element.inspect} @ #{formatted_stack.join(".")}" - end + def resolve_symbol_name(element) + case element + when Elements::Symbol + element.name + when Elements::SymbolLink + visit_Gem_SafeMarshal_Elements_SymbolLink(element).name + else + raise FormatError, "Expected symbol or symbol link, got #{element.inspect} @ #{formatted_stack.join(".")}" end end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index 38ac67be3add16..a5ca2dace10745 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1828,7 +1828,7 @@ def stub(name, val_or_callable, *block_args) end end - metaclass.send(:ruby2_keywords, name) if metaclass.respond_to?(:ruby2_keywords, true) + metaclass.send(:ruby2_keywords, name) yield self ensure diff --git a/test/rubygems/test_gem_command_manager.rb b/test/rubygems/test_gem_command_manager.rb index 889d5ce9e66a7a..e80b600ccac436 100644 --- a/test/rubygems/test_gem_command_manager.rb +++ b/test/rubygems/test_gem_command_manager.rb @@ -82,11 +82,7 @@ def test_find_command_unknown_suggestions message << "\nDid you mean? \"push\"" end - if e.respond_to?(:detailed_message) - actual_message = e.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 } - else - actual_message = e.message - end + actual_message = e.detailed_message(highlight: false).sub(/\A(.*?)(?: \(.+?\))/) { $1 } assert_equal message, actual_message end From 49df617361a9a92de1708ae8fdc63c6047fee551 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:27:20 +0900 Subject: [PATCH 08/22] [ruby/rubygems] Drop the fallbacks for openssl gems older than 2.2 Ruby 3.2 ships openssl 3.1, so PKey#public_to_der and SSLContext#min_version= are always available. get_public_key is documented to take a PKey, so the test that passed it a certificate now reads the key from the certificate directly. https://github.com/ruby/rubygems/commit/c76d1bab81 Co-Authored-By: Claude Fable 5.1 --- lib/bundler/cli/doctor/ssl.rb | 12 ++++-------- lib/rubygems/security.rb | 8 +------- test/rubygems/test_gem_security.rb | 2 +- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/lib/bundler/cli/doctor/ssl.rb b/lib/bundler/cli/doctor/ssl.rb index 21fc4edf2d39c8..75de957bfe0427 100644 --- a/lib/bundler/cli/doctor/ssl.rb +++ b/lib/bundler/cli/doctor/ssl.rb @@ -124,14 +124,10 @@ def warn_on_unsupported_tls12 ctx = OpenSSL::SSL::SSLContext.new supported = true - if ctx.respond_to?(:min_version=) - begin - ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION - rescue OpenSSL::SSL::SSLError, NameError - supported = false - end - else - supported = OpenSSL::SSL::SSLContext::METHODS.include?(:TLSv1_2) # rubocop:disable Naming/VariableNumber + begin + ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION + rescue OpenSSL::SSL::SSLError, NameError + supported = false end Bundler.ui.warn(<<~EOM) unless supported diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb index 1d86ecc909cc8b..1a7b63683c3929 100644 --- a/lib/rubygems/security.rb +++ b/lib/rubygems/security.rb @@ -440,13 +440,7 @@ def self.create_cert(subject, key, age = ONE_YEAR, extensions = EXTENSIONS, seri # Gets the right public key from a PKey instance def self.get_public_key(key) - # Ruby 3.0 (Ruby/OpenSSL 2.2) or later - return OpenSSL::PKey.read(key.public_to_der) if key.respond_to?(:public_to_der) - return key.public_key unless key.is_a?(OpenSSL::PKey::EC) - - ec_key = OpenSSL::PKey::EC.new(key.group.curve_name) - ec_key.public_key = key.public_key - ec_key + OpenSSL::PKey.read(key.public_to_der) end ## diff --git a/test/rubygems/test_gem_security.rb b/test/rubygems/test_gem_security.rb index 28886cecb89b60..3c2687876ff2ad 100644 --- a/test/rubygems/test_gem_security.rb +++ b/test/rubygems/test_gem_security.rb @@ -494,7 +494,7 @@ def assert_sign(signing_cert, signing_key) cert.public_key = public_key signed = Gem::Security.sign cert, key, signing_cert, 60 - signed_public_key = Gem::Security.get_public_key(signed) + signed_public_key = signed.public_key assert_equal public_key.public_to_pem, signed_public_key.public_to_pem assert_equal signee.to_s, signed.subject.to_s From 2ec3ef4ae11dcf5749ad0207d9e13941388288c4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:28:05 +0900 Subject: [PATCH 09/22] [ruby/rubygems] Drop the YAMLTree.create shim for psych older than 2.0 Psych::Visitors::YAMLTree.create has existed since psych 2.0, so the shim was never defined on any supported Ruby. https://github.com/ruby/rubygems/commit/242ca5848b Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/psych_tree.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/rubygems/psych_tree.rb b/lib/rubygems/psych_tree.rb index 8b4c425a3340ba..396b4cb98ef330 100644 --- a/lib/rubygems/psych_tree.rb +++ b/lib/rubygems/psych_tree.rb @@ -3,10 +3,6 @@ module Gem if defined? ::Psych::Visitors class NoAliasYAMLTree < Psych::Visitors::YAMLTree - def self.create - new({}) - end unless respond_to? :create - def visit_String(str) return super unless str == "=" # or whatever you want From 0772e42d0418cc4a138d8dfe7de936282cc8d8a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:11:43 +0000 Subject: [PATCH 10/22] Bump the github-actions group across 1 directory with 2 updates Bumps the github-actions group with 2 updates in the / directory: [ruby/setup-ruby](https://github.com/ruby/setup-ruby) and [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `ruby/setup-ruby` from 1.322.0 to 1.323.0 - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/bec3f19a76460dbe12f60def7d1a77585f07516c...984c0c890880bbf811283d6f09c4607c62d210a4) Updates `taiki-e/install-action` from 2.87.12 to 2.87.13 - [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/3f74d7c16a4242f1c95561e98edc25d36adb4375...26e9283f268b880168bdbd2c545dfcd60ec2c6ab) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.323.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: taiki-e/install-action dependency-version: 2.87.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/annocheck.yml | 2 +- .github/workflows/auto_review_pr.yml | 2 +- .github/workflows/baseruby.yml | 2 +- .github/workflows/bundled_gems.yml | 2 +- .github/workflows/check_misc.yml | 2 +- .github/workflows/modgc.yml | 2 +- .github/workflows/parse_y.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/spec_guards.yml | 2 +- .github/workflows/sync_default_gems.yml | 2 +- .github/workflows/tarball-ubuntu.yml | 2 +- .github/workflows/tarball-windows.yml | 2 +- .github/workflows/ubuntu.yml | 2 +- .github/workflows/wasm.yml | 2 +- .github/workflows/windows.yml | 4 ++-- .github/workflows/yjit-ubuntu.yml | 2 +- .github/workflows/zjit-macos.yml | 2 +- .github/workflows/zjit-ubuntu.yml | 4 ++-- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/annocheck.yml b/.github/workflows/annocheck.yml index 88895f0e8452b6..6378861a2111df 100644 --- a/.github/workflows/annocheck.yml +++ b/.github/workflows/annocheck.yml @@ -67,7 +67,7 @@ jobs: sparse-checkout: /.github persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/auto_review_pr.yml b/.github/workflows/auto_review_pr.yml index 7e6dd984c6730d..6e0c5c6e79e889 100644 --- a/.github/workflows/auto_review_pr.yml +++ b/.github/workflows/auto_review_pr.yml @@ -29,7 +29,7 @@ jobs: with: persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.4' bundler: none diff --git a/.github/workflows/baseruby.yml b/.github/workflows/baseruby.yml index df907ce429da86..9531352602b4ba 100644 --- a/.github/workflows/baseruby.yml +++ b/.github/workflows/baseruby.yml @@ -48,7 +48,7 @@ jobs: - ruby-3.3 steps: - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: ${{ matrix.ruby }} bundler: none diff --git a/.github/workflows/bundled_gems.yml b/.github/workflows/bundled_gems.yml index 5cae6f539af9ab..79b05874a0ea71 100644 --- a/.github/workflows/bundled_gems.yml +++ b/.github/workflows/bundled_gems.yml @@ -38,7 +38,7 @@ jobs: with: token: ${{ (github.repository == 'ruby/ruby' && !startsWith(github.event_name, 'pull')) && secrets.MATZBOT_AUTO_UPDATE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: 4.0 diff --git a/.github/workflows/check_misc.yml b/.github/workflows/check_misc.yml index 680290aa22d81b..e98567e7fad83b 100644 --- a/.github/workflows/check_misc.yml +++ b/.github/workflows/check_misc.yml @@ -23,7 +23,7 @@ jobs: token: ${{ (github.repository == 'ruby/ruby' && !startsWith(github.event_name, 'pull')) && secrets.MATZBOT_AUTO_UPDATE_TOKEN || secrets.GITHUB_TOKEN }} persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: head diff --git a/.github/workflows/modgc.yml b/.github/workflows/modgc.yml index 021aa6d132fda4..e74b028f091ad8 100644 --- a/.github/workflows/modgc.yml +++ b/.github/workflows/modgc.yml @@ -67,7 +67,7 @@ jobs: uses: ./.github/actions/setup/ubuntu if: ${{ contains(matrix.os, 'ubuntu') }} - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/parse_y.yml b/.github/workflows/parse_y.yml index a908f082e181f8..138acebc5e0fc5 100644 --- a/.github/workflows/parse_y.yml +++ b/.github/workflows/parse_y.yml @@ -59,7 +59,7 @@ jobs: - uses: ./.github/actions/setup/ubuntu - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8c07aa570ae10f..f5b2344231c8b0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ jobs: with: persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: 3.3.4 diff --git a/.github/workflows/spec_guards.yml b/.github/workflows/spec_guards.yml index 1d707eaa04d582..2f950b52064bd9 100644 --- a/.github/workflows/spec_guards.yml +++ b/.github/workflows/spec_guards.yml @@ -49,7 +49,7 @@ jobs: with: persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: ${{ matrix.ruby }} bundler: none diff --git a/.github/workflows/sync_default_gems.yml b/.github/workflows/sync_default_gems.yml index 5bbcde76e59e5b..0adc2cd75fa655 100644 --- a/.github/workflows/sync_default_gems.yml +++ b/.github/workflows/sync_default_gems.yml @@ -39,7 +39,7 @@ jobs: with: token: ${{ github.repository == 'ruby/ruby' && secrets.MATZBOT_AUTO_UPDATE_TOKEN || secrets.GITHUB_TOKEN }} - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.4' bundler: none diff --git a/.github/workflows/tarball-ubuntu.yml b/.github/workflows/tarball-ubuntu.yml index 3aba0d3d8857e3..0471e1b6efc77b 100644 --- a/.github/workflows/tarball-ubuntu.yml +++ b/.github/workflows/tarball-ubuntu.yml @@ -43,7 +43,7 @@ jobs: set -x sudo apt-get update -q sudo apt-get install --no-install-recommends -q -y build-essential libssl-dev libyaml-dev zlib1g-dev libffi-dev libgmp-dev bison- autoconf- - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.2' # test-bundled-gems requires executable host ruby diff --git a/.github/workflows/tarball-windows.yml b/.github/workflows/tarball-windows.yml index e43468d25a9a3d..0f34ee06ee11e5 100644 --- a/.github/workflows/tarball-windows.yml +++ b/.github/workflows/tarball-windows.yml @@ -49,7 +49,7 @@ jobs: - run: md build working-directory: - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.2' bundler: none diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 20a18ecb3b3dd6..087f99f3eba1d7 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -78,7 +78,7 @@ jobs: with: arch: ${{ matrix.arch }} - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 46b4ee06c747f0..01cab4d42cf8c4 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -65,7 +65,7 @@ jobs: sparse-checkout: /.github persist-credentials: false - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c8a1f9fbb1903d..1888c5ab1d1517 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -64,7 +64,7 @@ jobs: - run: md build working-directory: - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: # windows-11-arm has only 3.4.1, 3.4.2, 3.4.3, head ruby-version: ${{ !endsWith(matrix.os, 'arm') && '3.1' || '3.4' }} @@ -266,7 +266,7 @@ jobs: - run: md build working-directory: - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/yjit-ubuntu.yml b/.github/workflows/yjit-ubuntu.yml index 059683c139055e..d5dc60c629200a 100644 --- a/.github/workflows/yjit-ubuntu.yml +++ b/.github/workflows/yjit-ubuntu.yml @@ -138,7 +138,7 @@ jobs: - uses: ./.github/actions/setup/ubuntu - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none diff --git a/.github/workflows/zjit-macos.yml b/.github/workflows/zjit-macos.yml index df6140edecffce..d3de016ff5cf07 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@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + - uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13 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 45fd662d6f64e6..59d5f34726dc44 100644 --- a/.github/workflows/zjit-ubuntu.yml +++ b/.github/workflows/zjit-ubuntu.yml @@ -147,12 +147,12 @@ jobs: - uses: ./.github/actions/setup/ubuntu - - uses: ruby/setup-ruby@bec3f19a76460dbe12f60def7d1a77585f07516c # v1.322.0 + - uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0 with: ruby-version: '3.1' bundler: none - - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + - uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} From 5a8c2ac227df106bf3a00a10174281d1da8b62bb Mon Sep 17 00:00:00 2001 From: git Date: Fri, 18 Sep 2026 02:36:48 +0000 Subject: [PATCH 11/22] [DOC] Update bundled gems list at 0772e42d0418cc4a138d8dfe7de936 --- NEWS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index a6ebfa69a5f508..61fd0c1801b372 100644 --- a/NEWS.md +++ b/NEWS.md @@ -204,7 +204,7 @@ They are still available on rubygems.org and can be installed with * 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] * error_highlight 0.7.2 * io-console 0.9.4 - * 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], [v0.9.3][io-console-v0.9.3] + * 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], [v0.9.3][io-console-v0.9.3], [v0.9.4][io-console-v0.9.4] * io-wait 999.999.999 * ipaddr 1.2.9 * 1.2.8 to [v1.2.9][ipaddr-v1.2.9] @@ -551,6 +551,7 @@ A lot of work has gone into making Ractors more stable, performant, and usable. [io-console-v0.9.1]: https://github.com/ruby/io-console/releases/tag/v0.9.1 [io-console-v0.9.2]: https://github.com/ruby/io-console/releases/tag/v0.9.2 [io-console-v0.9.3]: https://github.com/ruby/io-console/releases/tag/v0.9.3 +[io-console-v0.9.4]: https://github.com/ruby/io-console/releases/tag/v0.9.4 [ipaddr-v1.2.9]: https://github.com/ruby/ipaddr/releases/tag/v1.2.9 [json-v2.18.1]: https://github.com/ruby/json/releases/tag/v2.18.1 [json-v2.19.0]: https://github.com/ruby/json/releases/tag/v2.19.0 From 1d24919a8b9a40beb5e8ac22c238954c870414d5 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:43:40 +0900 Subject: [PATCH 12/22] [ruby/rubygems] Treat an empty password as no password in Gem::Uri `https://TOKEN:@host/` parses with an empty-string password, so `token?` did not recognize it and `redacted` kept the token in clear text as `https://TOKEN:REDACTED@host/`. `redact_credentials_from` also matched the empty string and inserted `REDACTED` at the start of the message. https://github.com/ruby/rubygems/commit/860d7b0390 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/uri.rb | 4 ++-- test/rubygems/test_gem_uri.rb | 4 ++++ test/rubygems/test_remote_fetch_error.rb | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/uri.rb b/lib/rubygems/uri.rb index d729c67d26398b..b10b21f1b6895d 100644 --- a/lib/rubygems/uri.rb +++ b/lib/rubygems/uri.rb @@ -109,7 +109,7 @@ def valid_uri? end def password? - !!password + !password.nil? && !password.empty? end def oauth_basic? @@ -117,7 +117,7 @@ def oauth_basic? end def token? - !user.nil? && password.nil? + !user.nil? && !password? end def initialize_copy(original) diff --git a/test/rubygems/test_gem_uri.rb b/test/rubygems/test_gem_uri.rb index ce633c99b63b0a..2ed9f8c1c0442c 100644 --- a/test/rubygems/test_gem_uri.rb +++ b/test/rubygems/test_gem_uri.rb @@ -20,6 +20,10 @@ def test_redacted_with_token assert_equal "https://REDACTED@example.com", Gem::Uri.new("https://token@example.com").redacted.to_s end + def test_redacted_with_token_and_empty_password + assert_equal "https://REDACTED@example.com", Gem::Uri.new("https://token:@example.com").redacted.to_s + end + def test_redacted_with_user_x_oauth_basic assert_equal "https://REDACTED@example.com", Gem::Uri.new("https://token:x-oauth-basic@example.com").redacted.to_s end diff --git a/test/rubygems/test_remote_fetch_error.rb b/test/rubygems/test_remote_fetch_error.rb index 5d9028ede724fa..a8ff7b26804ef9 100644 --- a/test/rubygems/test_remote_fetch_error.rb +++ b/test/rubygems/test_remote_fetch_error.rb @@ -8,6 +8,11 @@ def test_password_redacted refute_match(/secret/, error.to_s) end + def test_token_with_empty_password_redacted + error = Gem::RemoteFetcher::FetchError.new("There was an error fetching", "https://token:@gemsource.org") + assert_equal "There was an error fetching (https://REDACTED@gemsource.org)", error.to_s + end + def test_invalid_url error = Gem::RemoteFetcher::FetchError.new("There was an error fetching", "https://::gemsource.org") assert_equal error.to_s, "There was an error fetching (https://::gemsource.org)" From f9d2cd31e5a5678702f94a8742e5e37f118b2d6f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:45:35 +0900 Subject: [PATCH 13/22] [ruby/rubygems] Tolerate a URI without a host in ConnectionPools#no_proxy? `net_http_args` calls `no_proxy?` whether or not a proxy is configured, so a typo such as `https:/host` that URI parses with a nil host died with `NoMethodError` instead of reaching the connection error that reports the URI. https://github.com/ruby/rubygems/commit/7e1a37a6c3 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/request/connection_pools.rb | 3 +++ test/rubygems/test_gem_request_connection_pools.rb | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/lib/rubygems/request/connection_pools.rb b/lib/rubygems/request/connection_pools.rb index c5f5531570e474..7e3af3993eb248 100644 --- a/lib/rubygems/request/connection_pools.rb +++ b/lib/rubygems/request/connection_pools.rb @@ -53,6 +53,9 @@ def no_proxy?(host, env_no_proxy) # A lone "*" entry bypasses the proxy for every host return true if env_no_proxy.include?("*") + # A URI such as the typo "https:/host" has no host to match + return false if host.nil? + host = host.downcase env_no_proxy.any? do |pattern| diff --git a/test/rubygems/test_gem_request_connection_pools.rb b/test/rubygems/test_gem_request_connection_pools.rb index 3ab897048daa50..1b2a62b453c386 100644 --- a/test/rubygems/test_gem_request_connection_pools.rb +++ b/test/rubygems/test_gem_request_connection_pools.rb @@ -128,6 +128,13 @@ def test_net_http_args_proxy assert_equal ["example", 80, "proxy.example", 80, nil, nil], net_http_args end + def test_net_http_args_without_host + pools = Gem::Request::ConnectionPools.new nil, [] + + assert_equal [nil, 443], pools.send(:net_http_args, Gem::URI("https:/host"), nil) + assert_equal [nil, 443, "proxy.example", 80, nil, nil], pools.send(:net_http_args, Gem::URI("https:/host"), @proxy) + end + def test_net_http_args_no_proxy orig_no_proxy = ENV["no_proxy"] ENV["no_proxy"] = "example" From 8d02a1935291a56f07d08a17334e694ae7f134ed Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 16 Sep 2026 19:49:45 +0900 Subject: [PATCH 14/22] [ruby/rubygems] Align RemoteFetcher#fetch_http redirects with the compact index fetcher A relative Location died with `NoMethodError` because it was parsed on its own instead of against the request URI, 308 fell through to "Bad response", the non-https rejection printed the Location's credentials in clear text, and an absolute Location on the same host lost the userinfo that a relative one keeps. Gem::CompactIndexClient::HTTPFetcher already handles all four. https://github.com/ruby/rubygems/commit/1714f94872 Co-Authored-By: Claude Fable 5.1 --- lib/rubygems/remote_fetcher.rb | 8 +-- test/rubygems/test_gem_remote_fetcher.rb | 68 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/lib/rubygems/remote_fetcher.rb b/lib/rubygems/remote_fetcher.rb index f8f0ae64f08624..47a00dc1dfde0b 100644 --- a/lib/rubygems/remote_fetcher.rb +++ b/lib/rubygems/remote_fetcher.rb @@ -232,17 +232,19 @@ def fetch_http(uri, last_modified = nil, head = false, depth = 0) response.uri = uri head ? response : response.body when Gem::Net::HTTPMovedPermanently, Gem::Net::HTTPFound, Gem::Net::HTTPSeeOther, - Gem::Net::HTTPTemporaryRedirect then + Gem::Net::HTTPTemporaryRedirect, Gem::Net::HTTPPermanentRedirect then raise FetchError.new("too many redirects", uri) if depth > 10 unless location = response["Location"] raise FetchError.new("redirecting but no redirect location was given", uri) end - location = Gem::Uri.new location + location = uri + location if https?(uri) && !https?(location) - raise FetchError.new("redirecting to non-https resource: #{location}", uri) + raise FetchError.new("redirecting to non-https resource: #{Gem::Uri.redact(location)}", uri) end + # see Gem::CompactIndexClient::HTTPFetcher#fetch + location.userinfo = uri.userinfo if location.host == uri.host && !location.userinfo fetch_http(location, last_modified, head, depth + 1) else diff --git a/test/rubygems/test_gem_remote_fetcher.rb b/test/rubygems/test_gem_remote_fetcher.rb index 9171c56ee97d4b..ee7cc801da19da 100644 --- a/test/rubygems/test_gem_remote_fetcher.rb +++ b/test/rubygems/test_gem_remote_fetcher.rb @@ -604,6 +604,74 @@ def fetcher.request(uri, request_class, last_modified = nil) assert_equal "too many redirects (#{url})", e.message end + def test_fetch_http_redirects_relative_location + fetcher = Gem::RemoteFetcher.new nil + @fetcher = fetcher + url = "https://gems.example.com/redirect" + + def fetcher.request(uri, request_class, last_modified = nil) + (@requested ||= []) << uri.to_s + if @requested.size > 1 + res = Gem::Net::HTTPOK.new nil, 200, nil + def res.body + "real_path" + end + else + res = Gem::Net::HTTPPermanentRedirect.new nil, 308, nil + res.add_field "Location", "/real" + end + res + end + + data = fetcher.fetch_http Gem::URI.parse(url) + + assert_equal "real_path", data + assert_equal [url, "https://gems.example.com/real"], fetcher.instance_variable_get(:@requested) + end + + def test_fetch_http_redirects_keep_userinfo_on_same_host + fetcher = Gem::RemoteFetcher.new nil + @fetcher = fetcher + url = "https://user:pass@gems.example.com/redirect" + + def fetcher.request(uri, request_class, last_modified = nil) + (@requested ||= []) << uri.to_s + if @requested.size > 1 + res = Gem::Net::HTTPOK.new nil, 200, nil + def res.body + "real_path" + end + else + res = Gem::Net::HTTPFound.new nil, 302, nil + res.add_field "Location", "https://gems.example.com/real" + end + res + end + + data = fetcher.fetch_http Gem::URI.parse(url) + + assert_equal "real_path", data + assert_equal [url, "https://user:pass@gems.example.com/real"], fetcher.instance_variable_get(:@requested) + end + + def test_fetch_http_redirects_to_non_https_redacts_location + fetcher = Gem::RemoteFetcher.new nil + @fetcher = fetcher + url = "https://gems.example.com/redirect" + + def fetcher.request(uri, request_class, last_modified = nil) + res = Gem::Net::HTTPFound.new nil, 302, nil + res.add_field "Location", "http://user:secret@mirror.example.com/real" + res + end + + e = assert_raise Gem::RemoteFetcher::FetchError do + fetcher.fetch_http Gem::URI.parse(url) + end + + assert_equal "redirecting to non-https resource: http://user:REDACTED@mirror.example.com/real (#{url})", e.message + end + def test_fetch_http_redirects_without_location fetcher = Gem::RemoteFetcher.new nil @fetcher = fetcher From c914aeb622ce7337223cd5c23f30daf9f7d3e7c4 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 18 Sep 2026 09:12:39 +0900 Subject: [PATCH 15/22] Fix use-after-free in String#encode when string modified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Bug #22330] The following script demonstrates a use-afer-free where we see corruption: s = "あ" * 10_000 s.encode("US-ASCII", fallback: proc { |c| s.clear; "?" }) Raises may different errors such as: "\xCB" followed by "\xF5" on UTF-8 (Encoding::InvalidByteSequenceError) "\xF4" followed by "r" on UTF-8 (Encoding::InvalidByteSequenceError) "\x8B" on UTF-8 (Encoding::InvalidByteSequenceError) --- test/ruby/test_transcode.rb | 12 ++++++++++++ transcode.c | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/test/ruby/test_transcode.rb b/test/ruby/test_transcode.rb index 6c3ae3a109f7eb..cd03ace640509e 100644 --- a/test/ruby/test_transcode.rb +++ b/test/ruby/test_transcode.rb @@ -2283,6 +2283,18 @@ def test_fallback_grow_insert_buffer assert_equal(30000, r.bytesize) end + def test_fallback_modify_source_string + # [Bug #22330] + s = "\u3042" * 1000 + assert_raise_with_message(RuntimeError, /string modified/) do + s.encode("US-ASCII", + fallback: proc {|x| + s.clear + "?" + }) + end + end + def test_fallback_method def (fallback = "U+%.4X").escape(x) self % x.unpack("U") diff --git a/transcode.c b/transcode.c index 35e5b43dce9a15..a35541484889c3 100644 --- a/transcode.c +++ b/transcode.c @@ -2382,7 +2382,8 @@ transcode_loop(const unsigned char **in_pos, unsigned char **out_pos, const char *src_encoding, const char *dst_encoding, int ecflags, - VALUE ecopts) + VALUE ecopts, + VALUE source) { rb_econv_t *ec; rb_transcoding *last_tc; @@ -2392,6 +2393,8 @@ transcode_loop(const unsigned char **in_pos, unsigned char **out_pos, VALUE exc; VALUE fallback = Qnil; VALUE (*fallback_func)(VALUE, VALUE) = 0; + const unsigned char *source_start = *in_pos; + long source_len = in_stop - *in_pos; ec = rb_econv_open_opts(src_encoding, dst_encoding, ecflags, ecopts); if (!ec) @@ -2438,6 +2441,15 @@ transcode_loop(const unsigned char **in_pos, unsigned char **out_pos, rb_jump_tag(state); } + /* Ruby code run during the conversion (e.g. the fallback) may have + * modified the source string, invalidating the pointers into its + * buffer. */ + if ((const unsigned char *)RSTRING_PTR(source) != source_start || + RSTRING_LEN(source) != source_len) { + rb_econv_close(ec); + rb_raise(rb_eRuntimeError, "string modified"); + } + if (!UNDEF_P(rep) && !NIL_P(rep)) { ret = rb_econv_insert_output(ec, (const unsigned char *)RSTRING_PTR(rep), RSTRING_LEN(rep), rb_enc_name(rb_enc_get(rep))); @@ -2476,7 +2488,8 @@ transcode_loop(const unsigned char **in_pos, unsigned char **out_pos, const char *src_encoding, const char *dst_encoding, int ecflags, - VALUE ecopts) + VALUE ecopts, + VALUE source) { rb_econv_t *ec; rb_transcoding *last_tc; @@ -2876,7 +2889,7 @@ str_transcode0(int argc, VALUE *argv, VALUE *self, int ecflags, VALUE ecopts) dest = rb_str_tmp_new(blen); bp = (unsigned char *)RSTRING_PTR(dest); - transcode_loop(&fromp, &bp, (sp+slen), (bp+blen), dest, str_transcoding_resize, sname, dname, ecflags, ecopts); + transcode_loop(&fromp, &bp, (sp+slen), (bp+blen), dest, str_transcoding_resize, sname, dname, ecflags, ecopts, str); if (fromp != sp+slen) { rb_raise(rb_eArgError, "not fully converted, %"PRIdPTRDIFF" bytes left", sp+slen-fromp); } From c55886f946e8d17dbb9e4e95f1605d7db1fde2c0 Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Fri, 28 Aug 2026 11:25:01 -0400 Subject: [PATCH 16/22] Remove VM lock acquire during finalize_list and run_final Each objspace has its own finalizer list since RLGC landed so there aren't concurrency issues here. This used to be a global table that all Ractors shared. This affects only the default GC. --- gc/default/default.c | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index a7bc69371608f2..b7c4cb00d7256a 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -4150,8 +4150,8 @@ get_final(long i, void *data) return RARRAY_AREF(table, i + 1); } -static unsigned int -run_final(rb_objspace_t *objspace, VALUE zombie, unsigned int lev) +static void +run_final(rb_objspace_t *objspace, VALUE zombie) { if (RZOMBIE(zombie)->dfree) { RZOMBIE(zombie)->dfree(RZOMBIE(zombie)->data); @@ -4162,9 +4162,7 @@ run_final(rb_objspace_t *objspace, VALUE zombie, unsigned int lev) FL_UNSET(zombie, FL_FINALIZE); st_data_t table; if (st_delete(finalizer_table, &key, &table)) { - RB_GC_VM_UNLOCK(lev); rb_gc_run_obj_finalizer(RARRAY_AREF(table, 0), RARRAY_LEN(table) - 1, get_final, (void *)table); - lev = RB_GC_VM_LOCK(); } else { rb_bug("FL_FINALIZE flag is set, but finalizers are not found"); @@ -4173,7 +4171,6 @@ run_final(rb_objspace_t *objspace, VALUE zombie, unsigned int lev) else { GC_ASSERT(!st_lookup(finalizer_table, key, NULL)); } - return lev; } static void @@ -4186,9 +4183,7 @@ finalize_list(rb_objspace_t *objspace, VALUE zombie) next_zombie = RZOMBIE(zombie)->next; page = GET_HEAP_PAGE(zombie); - unsigned int lev = RB_GC_VM_LOCK(); - - lev = run_final(objspace, zombie, lev); + run_final(objspace, zombie); { GC_ASSERT(BUILTIN_TYPE(zombie) == T_ZOMBIE); GC_ASSERT(page->heap->final_slots_count > 0); @@ -4201,7 +4196,6 @@ finalize_list(rb_objspace_t *objspace, VALUE zombie) heap_page_add_free_region(objspace, page, zombie); page->heap->total_freed_objects++; } - RB_GC_VM_UNLOCK(lev); zombie = next_zombie; } From 11f6d3d68bba17f31064fcc3e12cfc347314dc4b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 18 Sep 2026 15:36:26 +1200 Subject: [PATCH 17/22] Flush buffered data in `BasicSocket#close_write` before shutdown. (#18900) When a socket has buffered output (`sync == false`) and `#close_write` is called, the write side was shut down via `shutdown(SHUT_WR)` without first flushing the buffer. This had two bad consequences: - the buffered bytes were silently dropped (never delivered to the peer); - a subsequent `#close` tried to flush the still-populated write buffer into the now `shutdown(SHUT_WR)` socket and raised `Errno::EPIPE`. Flush the buffer before shutting down the write side, matching the write-only branch (which flushes via `rb_io_close`) and the non-socket `IO#close_write` path. `IO#close_write` for a plain `IO` wrapping a socket fd is fixed in the same way. Assisted-By: devx/2c064143-8655-4631-a7cd-6395bb241bdc --- ext/socket/basicsocket.c | 6 ++++++ io.c | 10 ++++++++++ test/socket/test_basicsocket.rb | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/ext/socket/basicsocket.c b/ext/socket/basicsocket.c index 2fcae8eb54f37c..f5fb66ee5df2c3 100644 --- a/ext/socket/basicsocket.c +++ b/ext/socket/basicsocket.c @@ -157,6 +157,12 @@ bsock_close_write(VALUE sock) if (!(fptr->mode & FMODE_READABLE)) { return rb_io_close(sock); } + /* Flush any buffered (sync == false) data before shutting down the + * write side. Otherwise the buffered bytes are silently dropped here, + * and a subsequent #close would try to flush them into the now + * shutdown(SHUT_WR) socket and fail with EPIPE. This matches the + * write-only branch above, which flushes via rb_io_close(). */ + rb_io_flush(sock); shutdown(fptr->fd, SHUT_WR); fptr->mode &= ~FMODE_WRITABLE; diff --git a/io.c b/io.c index 309ecb971ade2d..9bc8e6866ce1f3 100644 --- a/io.c +++ b/io.c @@ -6102,6 +6102,16 @@ rb_io_close_write(VALUE io) #ifndef SHUT_WR # define SHUT_WR 1 #endif + /* Flush any buffered data before shutting down the write side. + * Otherwise the buffered bytes are silently dropped here, and a + * subsequent #close would try to flush them into the now + * shutdown(SHUT_WR) socket and fail with EPIPE. This matches the + * behaviour of the non-socket path below, which flushes via + * rb_io_close(). */ + if (fptr->mode & FMODE_WRITABLE) { + if (io_fflush(fptr) < 0) + rb_sys_fail_on_write(fptr); + } if (shutdown(fptr->fd, SHUT_WR) < 0) rb_sys_fail_path(fptr->pathv); fptr->mode &= ~FMODE_WRITABLE; diff --git a/test/socket/test_basicsocket.rb b/test/socket/test_basicsocket.rb index 8c1b434a83b434..3478749cb28ec3 100644 --- a/test/socket/test_basicsocket.rb +++ b/test/socket/test_basicsocket.rb @@ -142,6 +142,23 @@ def test_close_write end end + def test_close_write_flushes_buffered_data + socks do |sserv, ssock, csock| + ssock.sync = false + ssock.write("buffered") + + # close_write must flush the buffered bytes to the peer instead of + # silently dropping them and leaving them to be re-sent (and fail) + # on the subsequent #close. + ssock.close_write + assert_equal "buffered", csock.read(8) + + # #close after #close_write must not raise Errno::EPIPE from trying + # to flush an orphaned write buffer into the shutdown socket. + assert_nothing_raised { ssock.close } + end + end + def test_for_fd assert_raise(Errno::EBADF, '[ruby-core:72418] [Bug #11854]') do BasicSocket.for_fd(-1) From 9a9f3827c59751b43c20893b228ddbaa5b13a706 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 18 Sep 2026 08:22:38 +0900 Subject: [PATCH 18/22] Fix use-after-free in String#[]= when string modified Fixes the following crash: str = "hello" * 100 obj = Object.new obj.define_singleton_method(:to_str) do str.replace("") "x" end str[/h.l/] = obj --- string.c | 5 +++++ test/ruby/test_string.rb | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/string.c b/string.c index 4a1e3b01e7ed1a..19a96e5b139d63 100644 --- a/string.c +++ b/string.c @@ -5992,7 +5992,12 @@ rb_str_subpat_set(VALUE str, VALUE re, VALUE backref, VALUE val) } end = RMATCH_END(match, nth); len = end - start; + StringValue(val); + if (start + len > RSTRING_LEN(str)) { + rb_raise(rb_eRuntimeError, "string modified"); + } + enc = rb_enc_check_str(str, val); rb_str_update_0(str, start, len, val); rb_enc_associate(str, enc); diff --git a/test/ruby/test_string.rb b/test/ruby/test_string.rb index b7cb6fd4c3811d..aa99afb62006f5 100644 --- a/test/ruby/test_string.rb +++ b/test/ruby/test_string.rb @@ -242,6 +242,16 @@ def o.to_int; 2; end assert_raise(IndexError) {"foo"[RbConfig::LIMITS["LONG_MIN"]] = "l"} end + def test_ASET_string_modified_during_conversion + str = S("hello" * 100) + obj = Object.new + obj.define_singleton_method(:to_str) do + str.replace("") + "x" + end + assert_raise(RuntimeError) { str[/h.l/] = obj } + end + def test_CMP # '<=>' assert_equal(1, S("abcdef") <=> S("abcde")) assert_equal(0, S("abcdef") <=> S("abcdef")) From 801d2011b0df8efa8ed69752768f9efad6b04641 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 18 Sep 2026 12:39:20 +0900 Subject: [PATCH 19/22] [ruby/rubygems] Re-enable stdio capture tests under Ruby::Box ruby/ruby#18574 makes $stdout and $stderr reassignment reach Kernel#puts and Kernel#warn inside a box. The box lanes run on a ruby-core master build, which has the fix. https://bugs.ruby-lang.org/issues/21867 https://github.com/ruby/rubygems/commit/69e92e9fdb Co-Authored-By: Claude Opus 5 --- spec/bundler/bundler/plugin_spec.rb | 6 ------ test/rubygems/helper.rb | 10 ---------- test/rubygems/test_deprecate.rb | 3 --- test/rubygems/test_gem.rb | 2 -- test/rubygems/test_gem_commands_build_command.rb | 1 - test/rubygems/test_gem_commands_open_command.rb | 1 - test/rubygems/test_gem_config_file.rb | 1 - test/rubygems/test_gem_doctor.rb | 2 -- test/rubygems/test_gem_package.rb | 2 -- test/rubygems/test_gem_request_set.rb | 2 -- .../test_gem_request_set_gem_dependency_api.rb | 3 --- test/rubygems/test_gem_specification.rb | 6 ------ test/rubygems/test_gem_stub_specification.rb | 1 - test/rubygems/test_require.rb | 4 ---- 14 files changed, 44 deletions(-) diff --git a/spec/bundler/bundler/plugin_spec.rb b/spec/bundler/bundler/plugin_spec.rb index 5f5054b6842513..1e10036b63c24f 100644 --- a/spec/bundler/bundler/plugin_spec.rb +++ b/spec/bundler/bundler/plugin_spec.rb @@ -318,8 +318,6 @@ end it "executes the hook" do - skip "Ruby::Box ignores $stdout reassignment (https://bugs.ruby-lang.org/issues/21867)" if defined?(Ruby::Box) && Ruby::Box.enabled? - expect do Plugin.hook(Bundler::Plugin::Events::EVENT1) end.to output("hook for event 1\n").to_stdout @@ -333,8 +331,6 @@ RUBY it "evals plugins.rb once" do - skip "Ruby::Box ignores $stdout reassignment (https://bugs.ruby-lang.org/issues/21867)" if defined?(Ruby::Box) && Ruby::Box.enabled? - expect do Plugin.hook(Bundler::Plugin::Events::EVENT1) Plugin.hook(Bundler::Plugin::Events::EVENT2) @@ -348,8 +344,6 @@ RUBY it "is passed to the hook" do - skip "Ruby::Box ignores $stdout reassignment (https://bugs.ruby-lang.org/issues/21867)" if defined?(Ruby::Box) && Ruby::Box.enabled? - expect do Plugin.hook(Bundler::Plugin::Events::EVENT1) { puts "win" } end.to output("win\n").to_stdout diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index a5ca2dace10745..516a4fbdc5f5d2 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1451,16 +1451,6 @@ def ruby_box_enabled? defined?(Ruby::Box) && Ruby::Box.enabled? end - ## - # Ruby::Box gives each box detached copies of the stdio globals, so - # reassigning $stdout/$stderr cannot capture output written by Kernel#warn, - # Kernel#puts or subprocesses. Pends until the ruby-core fix for - # https://bugs.ruby-lang.org/issues/21867 lands. - - def pend_for_ruby_box_stdio_capture - pend "Ruby::Box breaks $stdout/$stderr capture (https://bugs.ruby-lang.org/issues/21867)" if ruby_box_enabled? - end - ## # Returns the make command for the current platform. For versions of Ruby # built on MS Windows with VC++ or Borland it will return 'nmake'. On all diff --git a/test/rubygems/test_deprecate.rb b/test/rubygems/test_deprecate.rb index 5700d356e7562e..bb6a0b5ceaaf38 100644 --- a/test/rubygems/test_deprecate.rb +++ b/test/rubygems/test_deprecate.rb @@ -132,7 +132,6 @@ def test_deprecated_method_calls_the_old_method end def test_deprecated_method_outputs_a_warning - pend_for_ruby_box_stdio_capture out, err = capture_output do thing = Thing.new thing.foo @@ -166,7 +165,6 @@ def execute end def test_deprecated_method_outputs_a_warning_old_way - pend_for_ruby_box_stdio_capture out, err = capture_output do thing = OtherThing.new thing.foo @@ -182,7 +180,6 @@ def test_deprecated_method_outputs_a_warning_old_way end def test_deprecated_method_when_class_overrides_format - pend_for_ruby_box_stdio_capture out, err = capture_output do thing = ThingWithFormat.new thing.foo diff --git a/test/rubygems/test_gem.rb b/test/rubygems/test_gem.rb index 36067549c86370..88b461a2a0a74f 100644 --- a/test/rubygems/test_gem.rb +++ b/test/rubygems/test_gem.rb @@ -1297,7 +1297,6 @@ def test_self_try_activate_missing_prerelease end def test_self_try_activate_missing_extensions - pend_for_ruby_box_stdio_capture spec = util_spec "ext", "1" do |s| s.extensions = %w[ext/extconf.rb] s.installed_by_version = v("2.2") @@ -1353,7 +1352,6 @@ def test_setting_paths_does_not_mutate_parameter_object end def test_deprecated_paths= - pend_for_ruby_box_stdio_capture stdout, stderr = capture_output do Gem.paths = { "GEM_HOME" => Gem.paths.home, "GEM_PATH" => [Gem.paths.home, "foo"] } diff --git a/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb index 682d3fa935b15b..e1f18e0218aa0c 100644 --- a/test/rubygems/test_gem_commands_build_command.rb +++ b/test/rubygems/test_gem_commands_build_command.rb @@ -405,7 +405,6 @@ def test_execute_strict_with_warnings end def test_execute_bad_spec - pend_for_ruby_box_stdio_capture @gem.date = "2010-11-08" gemspec_file = File.join(@tempdir, @gem.spec_name) diff --git a/test/rubygems/test_gem_commands_open_command.rb b/test/rubygems/test_gem_commands_open_command.rb index c30117a58ecc22..3a774a9343c08b 100644 --- a/test/rubygems/test_gem_commands_open_command.rb +++ b/test/rubygems/test_gem_commands_open_command.rb @@ -21,7 +21,6 @@ def gem(name, version = "1.0") end def test_execute - pend_for_ruby_box_stdio_capture omit "JRuby on Windows spawns the editor with a different cwd" if Gem.win_platform? && Gem.java_platform? @cmd.options[:args] = %w[foo] diff --git a/test/rubygems/test_gem_config_file.rb b/test/rubygems/test_gem_config_file.rb index 7120c49e327afb..2c33192a4b3a83 100644 --- a/test/rubygems/test_gem_config_file.rb +++ b/test/rubygems/test_gem_config_file.rb @@ -314,7 +314,6 @@ def test_handle_arguments_backtrace end def test_handle_arguments_debug - pend_for_ruby_box_stdio_capture assert_equal false, $DEBUG args = %w[--debug] diff --git a/test/rubygems/test_gem_doctor.rb b/test/rubygems/test_gem_doctor.rb index da625000ff9010..1554e7af128dd4 100644 --- a/test/rubygems/test_gem_doctor.rb +++ b/test/rubygems/test_gem_doctor.rb @@ -240,7 +240,6 @@ def test_doctor_preserves_valid_abi_scoped_gemspec end def test_doctor_removes_corrupt_abi_scoped_gemspec - pend_for_ruby_box_stdio_capture install_specs util_spec "regular_gem" spec = util_ca_spec "ca_gem", "1", "aabbccdd", @@ -287,7 +286,6 @@ def test_doctor_preserves_other_abi_dir end def test_doctor_does_not_recurse_into_abi_symlink - pend_for_ruby_box_stdio_capture pend "symlinks not supported" unless symlink_supported? install_specs util_spec "regular_gem" diff --git a/test/rubygems/test_gem_package.rb b/test/rubygems/test_gem_package.rb index 4e83b3a69b467b..b0935693d1c7e6 100644 --- a/test/rubygems/test_gem_package.rb +++ b/test/rubygems/test_gem_package.rb @@ -1538,7 +1538,6 @@ def test_verify_corrupt end def test_verify_corrupt_tar_metadata_entry - pend_for_ruby_box_stdio_capture gem = tar_file_header("metadata.gz", "", 0, 999, Time.now) File.open "corrupt.gem", "wb" do |io| @@ -1575,7 +1574,6 @@ def test_verify_corrupt_tar_checksums_entry end def test_verify_corrupt_tar_data_entry - pend_for_ruby_box_stdio_capture gem = tar_file_header("data.tar.gz", "", 0, 100, Time.now) File.open "corrupt.gem", "wb" do |io| diff --git a/test/rubygems/test_gem_request_set.rb b/test/rubygems/test_gem_request_set.rb index 60ff8724aef45a..8c8be04fb9f9a0 100644 --- a/test/rubygems/test_gem_request_set.rb +++ b/test/rubygems/test_gem_request_set.rb @@ -71,7 +71,6 @@ def test_install_from_gemdeps end def test_install_from_gemdeps_explain - pend_for_ruby_box_stdio_capture spec_fetcher do |fetcher| fetcher.gem "a", 2 end @@ -95,7 +94,6 @@ def test_install_from_gemdeps_explain end def test_install_from_gemdeps_explain_verbose - pend_for_ruby_box_stdio_capture spec_fetcher do |fetcher| fetcher.gem "a", 2 end diff --git a/test/rubygems/test_gem_request_set_gem_dependency_api.rb b/test/rubygems/test_gem_request_set_gem_dependency_api.rb index d8f4e7f6e92b4f..4b5eaa38eda8ef 100644 --- a/test/rubygems/test_gem_request_set_gem_dependency_api.rb +++ b/test/rubygems/test_gem_request_set_gem_dependency_api.rb @@ -78,7 +78,6 @@ def test_gem end def test_gem_duplicate - pend_for_ruby_box_stdio_capture @gda.gem "a" _, err = capture_output do @@ -129,7 +128,6 @@ def test_gem_bitbucket_expand_path end def test_gem_git_branch - pend_for_ruby_box_stdio_capture _, err = capture_output do @gda.gem "a", git: "git/a", branch: "other", tag: "v1" end @@ -151,7 +149,6 @@ def test_gem_git_gist end def test_gem_git_ref - pend_for_ruby_box_stdio_capture _, err = capture_output do @gda.gem "a", git: "git/a", ref: "abcd123", branch: "other" end diff --git a/test/rubygems/test_gem_specification.rb b/test/rubygems/test_gem_specification.rb index d07e558471074e..264c3585945d96 100644 --- a/test/rubygems/test_gem_specification.rb +++ b/test/rubygems/test_gem_specification.rb @@ -710,7 +710,6 @@ def test_self_attribute_names end def test_self_dirs_equals_with_unresolved_deps - pend_for_ruby_box_stdio_capture a = util_spec "a", 1 b = util_spec "b", 1 install_gem_user a @@ -1610,7 +1609,6 @@ def test_contains_requirable_file_eh end def test_contains_requirable_file_eh_extension - pend_for_ruby_box_stdio_capture ext_spec _, err = capture_output do @@ -3421,7 +3419,6 @@ def test_validate_files end def test_unresolved_specs - pend_for_ruby_box_stdio_capture specification = Gem::Specification.clone set_orig specification @@ -3448,7 +3445,6 @@ def test_unresolved_specs end def test_unresolved_specs_with_versions - pend_for_ruby_box_stdio_capture specification = Gem::Specification.clone set_orig specification @@ -3481,7 +3477,6 @@ def test_unresolved_specs_with_versions end def test_unresolved_specs_with_duplicated_versions - pend_for_ruby_box_stdio_capture specification = Gem::Specification.clone set_orig specification @@ -3535,7 +3530,6 @@ def test_unresolved_specs_with_unrestricted_deps_on_default_gems end def test_duplicate_runtime_dependency - pend_for_ruby_box_stdio_capture expected = "WARNING: duplicated b dependency [\"~> 3.0\", \"~> 3.0\"]\n" out, err = capture_output do @a1.add_dependency "b", "~> 3.0", "~> 3.0" diff --git a/test/rubygems/test_gem_stub_specification.rb b/test/rubygems/test_gem_stub_specification.rb index 66bdd3d3fbbb23..1aa3b6532436ec 100644 --- a/test/rubygems/test_gem_stub_specification.rb +++ b/test/rubygems/test_gem_stub_specification.rb @@ -94,7 +94,6 @@ def test_contains_requirable_file_eh end def test_contains_requirable_file_eh_extension - pend_for_ruby_box_stdio_capture stub_with_extension do |stub| _, err = capture_output do if RUBY_ENGINE == "jruby" diff --git a/test/rubygems/test_require.rb b/test/rubygems/test_require.rb index ef1bb2e465a22b..6816e42bfe131b 100644 --- a/test/rubygems/test_require.rb +++ b/test/rubygems/test_require.rb @@ -718,7 +718,6 @@ def test_require_bundler ["", "Kernel."].each do |prefix| define_method "test_no_kernel_require_in_#{prefix.tr(".", "_")}warn_with_uplevel" do - pend_for_ruby_box_stdio_capture Dir.mktmpdir("warn_test") do |dir| File.write(dir + "/sub.rb", "#{prefix}warn 'uplevel', 'test', uplevel: 1\n") File.write(dir + "/main.rb", "require 'sub'\n") @@ -734,7 +733,6 @@ def test_require_bundler end define_method "test_no_other_behavioral_changes_with_#{prefix.tr(".", "_")}warn" do - pend_for_ruby_box_stdio_capture Dir.mktmpdir("warn_test") do |dir| File.write(dir + "/main.rb", "#{prefix}warn({x:1}, {y:2}, [])\n") _, err = capture_subprocess_io do @@ -750,7 +748,6 @@ def test_require_bundler end def test_no_crash_when_overriding_warn_with_warning_module - pend_for_ruby_box_stdio_capture Dir.mktmpdir("warn_test") do |dir| File.write(dir + "/main.rb", "module Warning; def warn(str); super; end; end; warn 'Foo Bar'") _, err = capture_subprocess_io do @@ -765,7 +762,6 @@ def test_no_crash_when_overriding_warn_with_warning_module end def test_expected_backtrace_location_when_inheriting_from_basic_object_and_including_kernel - pend_for_ruby_box_stdio_capture Dir.mktmpdir("warn_test") do |dir| File.write(dir + "/main.rb", "\nrequire 'sub'\n") File.write(dir + "/sub.rb", <<-'RUBY') From 21a472e8302732c883e0668a40911f14116e6be3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 18 Sep 2026 13:14:54 +0900 Subject: [PATCH 20/22] [ruby/rubygems] Always require the vendored SecureRandom in Gem::AtomicFileWriter `defined?(Gem::SecureRandom)` is already true while another thread is still loading the file and has not yet extended it with `Random::Formatter`, so a parallel installer worker could skip the require and fail with `NoMethodError` on `Gem::SecureRandom.hex`. Requiring it unconditionally makes that worker wait until the load finishes. https://github.com/ruby/rubygems/commit/bf5ac6dd11 Co-Authored-By: Claude Opus 5 --- lib/rubygems/util/atomic_file_writer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubygems/util/atomic_file_writer.rb b/lib/rubygems/util/atomic_file_writer.rb index 6e2bd1eff25af4..e031a52781f373 100644 --- a/lib/rubygems/util/atomic_file_writer.rb +++ b/lib/rubygems/util/atomic_file_writer.rb @@ -14,7 +14,7 @@ class AtomicFileWriter def self.open(file_name) # Vendored, because activating the securerandom default gem here pins it for # the rest of the process and conflicts with gems that need a newer one. - require_relative "../vendored_securerandom" unless defined?(Gem::SecureRandom) + require_relative "../vendored_securerandom" old_stat = begin File.stat(file_name) From 3b26b003152542a75e71a5431d72951538d9f1f2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 18 Sep 2026 17:27:53 +1200 Subject: [PATCH 21/22] Flush coalesced data in `io_binwritev` under sync mode. (#18905) `IO#write` with many arguments in sync mode was not observably atomic: when the argument count exceeded `IOV_MAX`, the internal writev path coalesced the trailing data into the write buffer and returned without flushing, so nothing reached the peer until the next flush or `#close`. `io_binwritev` is only reached in sync/TTY mode (see `io_writev`), but its "append to buffer when it fits" branch returned without flushing. Flush the coalesced buffer before returning so that a multi-argument write is immediately written out, matching the single-argument path. Before, a pipe in the default `sync == true` mode: w.write(*(["a"] * 1024)) r.read_nonblock(1024) # => nothing available until w.close Assisted-By: devx/2c064143-8655-4631-a7cd-6395bb241bdc --- io.c | 8 ++++++++ test/ruby/test_io.rb | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/io.c b/io.c index 9bc8e6866ce1f3..07ac3d7b33a2dc 100644 --- a/io.c +++ b/io.c @@ -2235,6 +2235,14 @@ io_binwritev(struct iovec *iov, int iovcnt, rb_io_t *fptr) fptr->wbuf.len += total; + /* io_binwritev is only reached in sync/TTY mode (it is called only + * from io_fwritev, which io_writev uses only when FMODE_SYNC or + * FMODE_TTY is set), so the coalesced data must be flushed + * immediately rather than left in the buffer until the next flush + * or close. Otherwise a multi-argument write with many arguments + * would not be observably atomic under sync. */ + if (io_fflush(fptr) < 0) return -1; + return total; } else { diff --git a/test/ruby/test_io.rb b/test/ruby/test_io.rb index 0679fe3587b99f..112a4654921a3a 100644 --- a/test/ruby/test_io.rb +++ b/test/ruby/test_io.rb @@ -1585,6 +1585,21 @@ def test_write_with_many_arguments end end + def test_write_with_many_arguments_is_flushed_when_sync + # Under sync mode, write(*args) must be observably atomic: all data must + # reach the peer immediately, not be left buffered until close. This + # covers argument counts above IOV_MAX, where the internal writev path + # previously coalesced into the buffer without flushing. + [10, 1023, 1024, 2000].each do |n| + IO.pipe do |r, w| + assert_predicate(w, :sync) + w.write(*(["a"] * n)) + assert_equal("a" * n, r.read_nonblock(n), + "sync write with #{n} arguments was not flushed") + end + end + end + def test_write_with_multiple_nonstring_arguments assert_in_out_err([], "STDOUT.write(:foo, :bar)", ["foobar"]) end From ab5d140de86473b6bfb8205a4622bf26d51edd69 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 18 Sep 2026 13:21:21 +0900 Subject: [PATCH 22/22] Fix buffer overflow in String#slice! when string modified The following script causes a buffer overflow and returns corrupted strings because it's reading past the end of the string buffer: s = "x" + "l" * 3999 obj = Object.new obj.define_singleton_method(:to_int) do s.clear 0 end p s.slice!(/l+$/, obj) Outputs corrupted string that looks like: "\xEA\x94\xEA\xCE`\u0000\u0000`\x88n\xEFC --- string.c | 3 +++ test/ruby/test_string.rb | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/string.c b/string.c index 19a96e5b139d63..11b2d5757ebdaa 100644 --- a/string.c +++ b/string.c @@ -6138,6 +6138,9 @@ rb_str_slice_bang(int argc, VALUE *argv, VALUE str) else if (nth >= num_regs) return Qnil; beg = RMATCH_BEG(match, nth); len = RMATCH_END(match, nth) - beg; + /* Converting the backref may have modified the string. */ + if (beg > RSTRING_LEN(str)) return Qnil; + if (len > RSTRING_LEN(str) - beg) len = RSTRING_LEN(str) - beg; goto subseq; } else if (argc == 2) { diff --git a/test/ruby/test_string.rb b/test/ruby/test_string.rb index aa99afb62006f5..5fb76986cf5791 100644 --- a/test/ruby/test_string.rb +++ b/test/ruby/test_string.rb @@ -2268,6 +2268,16 @@ def test_slice! assert_raise(ArgumentError) { a.slice! } end + def test_slice_bang_string_modified + obj = Object.new + s = S("x" + "l" * 3999) + obj.define_singleton_method(:to_int) do + s.clear + 0 + end + assert_nil(s.slice!(/l+$/, obj)) + end + def test_split fs, $; = $;, nil assert_equal([S("a"), S("b"), S("c")], S(" a b\t c ").split)