From ae5ca459442a22ab5966e1aff644e18473f87369 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Wed, 9 Sep 2026 19:13:46 +0000 Subject: [PATCH 1/8] Free messages addressed to a port that died before delivery A message sent to a Ractor::Port is enqueued on the receiving Ractor's recv_queue, and only moved to that port's own queue when the Ractor receives or closes. rb_ractor_reap_dead_ports() sweeps the port queues held in sync.ports, so a message whose port became unreachable before any delivery happened was never reaped: it stayed on recv_queue, off-heap and invisible to ObjectSpace, for the life of the process. 200.times { p = Ractor::Port.new; 1000.times { p << ("z" * 4000) } } grew without bound (RSS 858 -> 1692 -> 2523 MB over three such passes), and needs no Ractor at all to hit. Closing the port, or any receive on the same Ractor, moved the messages to the port queue and hid the leak. Sweep recv_queue in the reap as well, dropping the baskets whose port is no longer in the table. The reap takes no sync lock: what keeps out the foreign senders that write recv_queue is the caller's gate in rb_ractor_finish_marking(), where a global GC has the world stopped and a single objspace has exactly one live Ractor. Assert that at the callee, so a second caller cannot lose the guarantee silently. [Bug #22122] Co-Authored-By: Claude Opus 5 (1M context) --- ractor_sync.c | 22 ++++++++++++++++++++++ test/ruby/test_ractor.rb | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/ractor_sync.c b/ractor_sync.c index 3cc61f52d19706..eda4754236a7f4 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -687,11 +687,33 @@ ractor_reap_dead_ports_i(st_data_t port_id, st_data_t val, st_data_t dat) } } +/* A message is only moved from recv_queue to its port queue when the owner receives or + * closes, so one addressed to a port that died first is left here, out of the sweep + * above. Its port is gone from the table by now: drop it. */ +static void +ractor_reap_undeliverable_messages(rb_ractor_t *r) +{ + struct ractor_queue *recv_q = r->sync.recv_queue; + if (recv_q == NULL) return; + + struct ractor_basket *b, *nxt; + ccan_list_for_each_safe(&recv_q->set, b, nxt, node) { + if (!st_lookup(r->sync.ports, b->port_id, NULL)) { + ccan_list_del_init(&b->node); + ractor_basket_free(b); + } + } +} + void rb_ractor_reap_dead_ports(rb_ractor_t *r) { + /* No sync lock here: the caller's gate is what keeps foreign senders out. */ + VM_ASSERT(rb_gc_single_objspace_p() || rb_gc_during_global_gc_p()); + if (r->sync.ports) { st_foreach(r->sync.ports, ractor_reap_dead_ports_i, 0); + ractor_reap_undeliverable_messages(r); } } diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 5b21ee361dfad2..59299229feb679 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -984,4 +984,22 @@ def test_attached_object_of_unshareable_object assert_equal true, Ractor.new { own = Object.new; own.singleton_class.attached_object.equal?(own) }.value RUBY end + + def test_port_undelivered_message_does_not_leak + omit 'not fixed for mmtk: it never calls rb_ractor_finish_marking, where the reap runs' unless GC.config[:implementation] == 'default' + # A message is only moved out of the receiving Ractor's incoming queue when it + # receives or closes. One addressed to a port that became unreachable first used to + # stay there for the life of the process, off-heap and invisible to ObjectSpace. + assert_no_memory_leak([], <<~'PREP', <<~'CODE', '[Bug #22122]', rss: true) + def t + port = Ractor::Port.new + 5.times { port << ("z" * (4 << 20)) } + end + # A few large payloads rather than many small ones, and a baseline taken at the + # high-water mark: small-allocation RSS creep alone reached 2.5x on macOS. + 5.times { t; GC.start } + PREP + 30.times { t; GC.start } + CODE + end end From a4b95bdd7e4af99db5ed3a8506e8671698336a4a Mon Sep 17 00:00:00 2001 From: youdie006 Date: Thu, 10 Sep 2026 04:01:05 +0900 Subject: [PATCH 2/8] [ruby/json] Make State#configure and #merge only write the options they are given The pure-Ruby generator's private _configure gives every keyword a literal default, so a call that passes one option silently resets the other fifteen. The method is also aliased as merge, so a state built with indent/object_nl starts emitting compact JSON after any later configure call. The C extension does not behave this way. configure_state_i walks only the keys actually present in the hash, so State#merge really merges there. Default the keywords to the current ivars so the pure generator agrees. initialize already assigns every ivar to its literal default before calling _configure(**opts), so construction is unchanged. https://github.com/ruby/json/commit/46fbe24b04 --- test/json/json_generator_test.rb | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index fcfc968f21a918..1f4b5d9495256e 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -516,6 +516,47 @@ def test_configure_using_configure_and_merge assert_equal '5', state2.array_nl end + def test_configure_only_writes_the_string_options_it_is_given + state = JSON.state.new(indent: '1', space: '2', space_before: '3', object_nl: '4', array_nl: '5') + state.configure(space: '9') + assert_equal '1', state.indent + assert_equal '9', state.space + assert_equal '3', state.space_before + assert_equal '4', state.object_nl + assert_equal '5', state.array_nl + state.merge(array_nl: '8') + assert_equal '1', state.indent + assert_equal '9', state.space + assert_equal '3', state.space_before + assert_equal '4', state.object_nl + assert_equal '8', state.array_nl + end + + def test_configure_keeps_the_layout_of_a_pretty_state + state = JSON.state.new(indent: ' ', object_nl: "\n", array_nl: "\n") + state.configure(depth: 0) + assert_equal %({\n "foo":[\n 1\n ]\n}), state.generate({ 'foo' => [1] }) + end + + def test_configure_only_writes_the_other_options_it_is_given + omit 'JRuby resets the non-string options' if RUBY_ENGINE == 'jruby' + state = JSON.state.new(max_nesting: 3, allow_nan: true, ascii_only: true, script_safe: true) + state.configure(indent: '1') + assert_equal '1', state.indent + assert_equal 3, state.max_nesting + assert_equal true, state.allow_nan? + assert_equal true, state.ascii_only? + assert_equal true, state.script_safe? + end + + def test_configure_writes_a_string_option_given_as_nil + omit 'JRuby keeps the previous value for an explicit nil' if RUBY_ENGINE == 'jruby' + state = JSON.state.new(indent: '1', space: '2') + state.configure(indent: nil) + assert_equal '', state.indent + assert_equal '2', state.space + end + def test_configure_hash_conversion state = JSON.state.new state.configure(indent: '1') From 81048db3385346b860f7f20788c252df8fcee6f5 Mon Sep 17 00:00:00 2001 From: XrXr Date: Thu, 3 Sep 2026 23:55:02 -0400 Subject: [PATCH 3/8] YJIT: ZJIT: For init-time mmap failure, use abort() instead of rb_bug() It's not a bug to be out of memory. Though, the code did have a separate bug: perror() can fail to write to stderr and set `errno` which made the check that followed it questionable. When perror() succeeds it could clobber `errno` anyways, because the spec allows it. Just abort. --- jit.c | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/jit.c b/jit.c index 7f6f161f2f4cdf..24b7e1956a4171 100644 --- a/jit.c +++ b/jit.c @@ -796,12 +796,8 @@ rb_jit_reserve_addr_space(uint32_t mem_size) // Check that the memory mapping was successful if (mem_block == MAP_FAILED) { - perror("ruby: jit: mmap:"); - if(errno == ENOMEM) { - // No crash report if it's only insufficient memory - exit(EXIT_FAILURE); - } - rb_bug("mmap failed"); + perror("ruby: jit: Fatal mmap failure:"); + abort(); } return mem_block; From 1993cf947b29f009b12efe31e33ce476ded0c6b7 Mon Sep 17 00:00:00 2001 From: Takashi Kokubun Date: Tue, 8 Sep 2026 16:06:01 -0700 Subject: [PATCH 4/8] .github/labeler.yml: Skip labeling "jit" for backport PRs --- .github/labeler.yml | 66 +++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 9cc3a60e349dc6..3f50b6c84f2d73 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,40 +7,42 @@ Backport: - base-branch: 'ruby_4_\d' jit: -- changed-files: - - any-glob-to-any-file: - # JIT sources (Rust + C + Ruby) - - 'jit/**' - # All of {y,z}jit/ except the generated cruby_bindings.inc.rs files. - # `!(...)` can't span a `/`, so each pair splits the tree into - # non-Rust files and Rust files not named cruby_bindings.inc.rs; - # their union is everything but the bindings file. - - 'yjit/**/!(*.rs)' - - 'yjit/**/!(cruby_bindings.inc).rs' - - 'zjit/**/!(*.rs)' - - 'zjit/**/!(cruby_bindings.inc).rs' - - 'jit.c' - - 'jit_hook.rb' - - 'jit_undef.rb' - - '{y,z}jit.{c,h,rb}' +- all: + - base-branch: '^master$' + - changed-files: + - any-glob-to-any-file: + # JIT sources (Rust + C + Ruby) + - 'jit/**' + # All of {y,z}jit/ except the generated cruby_bindings.inc.rs files. + # `!(...)` can't span a `/`, so each pair splits the tree into + # non-Rust files and Rust files not named cruby_bindings.inc.rs; + # their union is everything but the bindings file. + - 'yjit/**/!(*.rs)' + - 'yjit/**/!(cruby_bindings.inc).rs' + - 'zjit/**/!(*.rs)' + - 'zjit/**/!(cruby_bindings.inc).rs' + - 'jit.c' + - 'jit_hook.rb' + - 'jit_undef.rb' + - '{y,z}jit.{c,h,rb}' - # Build + GC fast paths - - 'defs/jit.mk' - - 'gc/**/zjit_fastpath.h' + # Build + GC fast paths + - 'defs/jit.mk' + - 'gc/**/zjit_fastpath.h' - # Docs - - 'doc/jit/**' + # Docs + - 'doc/jit/**' - # Specs + tests - - 'spec/zjit.mspec' - - 'test/.excludes-zjit/**' - - 'test/lib/jit_support.rb' - - 'test/ruby/test_*jit*.rb' - - 'bootstraptest/test_*jit*.rb' + # Specs + tests + - 'spec/zjit.mspec' + - 'test/.excludes-zjit/**' + - 'test/lib/jit_support.rb' + - 'test/ruby/test_*jit*.rb' + - 'bootstraptest/test_*jit*.rb' - # Tooling - - 'tool/zjit_*' - - 'tool/ruby_vm/**/*zjit*' + # Tooling + - 'tool/zjit_*' + - 'tool/ruby_vm/**/*zjit*' - # CI - - '.github/workflows/*jit*.yml' + # CI + - '.github/workflows/*jit*.yml' From 5cf12a066bddf1b1db067a4d067d3d0043fb85c7 Mon Sep 17 00:00:00 2001 From: Takashi Kokubun Date: Tue, 8 Sep 2026 16:06:24 -0700 Subject: [PATCH 5/8] .github/labeler.yml: Skip labeling "jit" for dependabot PRs --- .github/labeler.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index 3f50b6c84f2d73..35214948e5880f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -9,6 +9,9 @@ Backport: jit: - all: - base-branch: '^master$' + # Skip dependabot's PRs, which bump actions in the CI files. + # They're noisy in notifications, and they're auto-merged anyway. + - head-branch: '^(?!dependabot/)' - changed-files: - any-glob-to-any-file: # JIT sources (Rust + C + Ruby) From 9c0db5ba0e18662c651cda859678c3390d65648c Mon Sep 17 00:00:00 2001 From: Luke Gruber Date: Wed, 9 Sep 2026 18:27:04 -0400 Subject: [PATCH 6/8] Symbol.all_symbols needs the VM barrier `rb_concurrent_set_foreach_with_replace` is not safe to run in the case of other running mutators or GCs. --- symbol.c | 1 + 1 file changed, 1 insertion(+) diff --git a/symbol.c b/symbol.c index a6b5741b45e004..678676b3d67c03 100644 --- a/symbol.c +++ b/symbol.c @@ -1217,6 +1217,7 @@ rb_sym_all_symbols(void) VALUE ary; GLOBAL_SYMBOLS_LOCKING(symbols) { + rb_vm_barrier(); ary = rb_ary_new2(rb_concurrent_set_size(symbols->sym_set)); rb_concurrent_set_foreach_with_replace(symbols->sym_set, symbols_i, (void *)ary); } From e25bcba0ed791e4b9210c3d6986211f22510c0b8 Mon Sep 17 00:00:00 2001 From: carlosdanielpohlod Date: Wed, 9 Sep 2026 18:57:30 -0300 Subject: [PATCH 7/8] [ruby/ipaddr] Fix link_local_multicast? mask for IPv4-mapped addresses The IPv4 branch matches 224.0.0.0/24, the Local Network Control Block, but the IPv4-mapped branch masked with 0xffff0000, which is a /16, even though its comment says /24. So an address was classified differently depending on whether it arrived as 224.0.1.1 or ::ffff:224.0.1.1, which is the form a dual-stack AF_INET6 socket hands you. The other predicates in the family already use the same mask on both branches. https://github.com/ruby/ipaddr/commit/eebe5b237d --- lib/ipaddr.rb | 2 +- test/test_ipaddr.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/ipaddr.rb b/lib/ipaddr.rb index a71772e32b05ad..e0c000621a2255 100644 --- a/lib/ipaddr.rb +++ b/lib/ipaddr.rb @@ -378,7 +378,7 @@ def link_local_multicast? when Socket::AF_INET6 @addr & 0xffff_0000_0000_0000_0000_0000_0000_0000 == 0xff02_0000_0000_0000_0000_0000_0000_0000 || # ff02::/16 (@addr >> 32 == 0xffff && ( - @addr & 0xffff0000 == 0xe0000000 # ::ffff:224.0.0.0/24 + @addr & 0xffffff00 == 0xe0000000 # ::ffff:224.0.0.0/24 )) else raise AddressFamilyError, "unsupported address family" diff --git a/test/test_ipaddr.rb b/test/test_ipaddr.rb index ab645325a30809..7ef2136807a9f0 100644 --- a/test/test_ipaddr.rb +++ b/test/test_ipaddr.rb @@ -651,6 +651,10 @@ def test_multicast? def test_link_local_multicast? assert_equal(true, IPAddr.new('224.0.0.0').link_local_multicast?) assert_equal(true, IPAddr.new('224.0.0.0/24').link_local_multicast?) + assert_equal(true, IPAddr.new('224.0.0.255').link_local_multicast?) + + assert_equal(false, IPAddr.new('224.0.1.1').link_local_multicast?) + assert_equal(false, IPAddr.new('224.1.0.0').link_local_multicast?) assert_equal(false, IPAddr.new('225.0.0.0').link_local_multicast?) assert_equal(false, IPAddr.new('225.0.0.0/24').link_local_multicast?) @@ -668,6 +672,9 @@ def test_link_local_multicast? assert_equal(false, IPAddr.new('::').link_local_multicast?) assert_equal(true, IPAddr.new('::ffff:224.0.0.0').link_local_multicast?) + assert_equal(true, IPAddr.new('::ffff:224.0.0.255').link_local_multicast?) + assert_equal(false, IPAddr.new('::ffff:224.0.1.1').link_local_multicast?) + assert_equal(false, IPAddr.new('::ffff:224.1.0.0').link_local_multicast?) assert_equal(false, IPAddr.new('::ffff:225.0.0.0').link_local_multicast?) end From a636d09d349d2596596c83c2ed985d8f6087e2d0 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sat, 5 Sep 2026 00:48:22 +0000 Subject: [PATCH 8/8] gc: let a new Ractor's objspace inherit GC.measure_total_time GC.measure_total_time= writes the flag of the calling Ractor's objspace, and every new objspace started with the flag on, so turning the timing off never reached the Ractors created afterwards: each of them kept paying two clock reads per GC phase, and GC.stat :time in a Ractor did not follow the setting. Start a Ractor's objspace with its creator's current value, the way ractor_create already carries over verbose and debug. The creating Ractor's thread is the one running this init, so the value is read without touching another Ractor's objspace. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5 (1M context) --- gc/default/default.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gc/default/default.c b/gc/default/default.c index 139fb142c0b24b..df1e914f14ea07 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -12609,7 +12609,6 @@ rb_gc_impl_objspace_init(void *objspace_ptr) gc_config_full_mark_set(TRUE); - objspace->flags.measure_gc = true; malloc_limit = gc_params.malloc_limit_min; objspace->shareable_objects_limit = SHAREABLE_OBJECTS_LIMIT_MIN; #ifdef MALLOC_COUNTERS_NEED_LOCK @@ -12648,6 +12647,10 @@ rb_gc_impl_objspace_init(void *objspace_ptr) #endif gc_params.heap_init_bytes = GC_HEAP_INIT_BYTES; } + // GC.measure_total_time= sets the caller's objspace only; a new Ractor's follows + // its creator's, which is the objspace running this init (main starts it on). + objspace->flags.measure_gc = global_objspace->main_objspace == objspace ? true + : ((rb_objspace_t *)rb_gc_get_objspace())->flags.measure_gc; rb_darray_make_without_gc(&objspace->heap_pages.sorted, 0); rb_darray_make_without_gc(&objspace->weak_references, 0);