From 88f1d98676d435a79e2086ed8054b459f1b4bd2a Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Tue, 2 Dec 2025 17:34:36 -0800 Subject: [PATCH] Fix allocationless anonymous splat keyword argument check Previously, if an argument splat and keywords are provided by the caller, it did not check whether the method/proc accepted keywords before avoiding the allocation. This is incorrect, because if the method/proc does not accept keywords, the keywords passed by the caller are added as a positional argument, so there must be an allocation to avoid mutating the positional splat argument. Add a check that if the caller passes keywords, the method/proc must accept keywords in order to optimize. If the caller passes a keyword splat, either the method/proc must accept keywords, or the keyword splat must be empty in order to optimize. If keywords are explicitly disallowed via `**nil`, the optimization should be skipped, because the array is mutated before the ArgumentError exception is raised. In addition to a test for the correct behavior, add an allocation test for a method that accepts an anonymous splat without keywords. Fixes [Bug #21757] --- test/ruby/test_allocation.rb | 53 ++++++++++++++++++++++++++++++++++++ test/ruby/test_call.rb | 29 ++++++++++++++++++++ vm_args.c | 26 ++++++++++++++---- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/test/ruby/test_allocation.rb b/test/ruby/test_allocation.rb index 0ff29b4794..d9ea3fb319 100644 --- a/test/ruby/test_allocation.rb +++ b/test/ruby/test_allocation.rb @@ -508,6 +508,59 @@ class TestAllocation < Test::Unit::TestCase RUBY end + def test_anonymous_splat_parameter + only_block = block.empty? ? block : block[2..] + check_allocations(<<~RUBY) + def self.anon_splat(*#{block}); end + + check_allocations(1, 1, "anon_splat(1, a: 2#{block})") + check_allocations(1, 1, "anon_splat(1, *empty_array, a: 2#{block})") + check_allocations(1, 1, "anon_splat(1, a:2, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(1, **empty_hash, a: 2#{block})") + + check_allocations(1, 0, "anon_splat(1, **nil#{block})") + check_allocations(1, 0, "anon_splat(1, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(1, **hash1#{block})") + check_allocations(1, 1, "anon_splat(1, *empty_array, **hash1#{block})") + check_allocations(1, 1, "anon_splat(1, **hash1, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(1, **empty_hash, **hash1#{block})") + + check_allocations(1, 0, "anon_splat(1, *empty_array#{block})") + check_allocations(1, 0, "anon_splat(1, *empty_array, *empty_array, **empty_hash#{block})") + + check_allocations(1, 1, "anon_splat(*array1, a: 2#{block})") + + check_allocations(1, 0, "anon_splat(*nil, **nill#{block})") + check_allocations(0, 0, "anon_splat(*array1, **nill#{block})") + check_allocations(0, 0, "anon_splat(*array1, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(*array1, **hash1#{block})") + check_allocations(1, 1, "anon_splat(*array1, *empty_array, **hash1#{block})") + + check_allocations(1, 0, "anon_splat(*array1, *empty_array#{block})") + check_allocations(1, 0, "anon_splat(*array1, *empty_array, **empty_hash#{block})") + + check_allocations(1, 1, "anon_splat(*array1, *empty_array, a: 2, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(*array1, *empty_array, **hash1, **empty_hash#{block})") + + check_allocations(1, 0, "anon_splat(#{only_block})") + check_allocations(1, 1, "anon_splat(a: 2#{block})") + check_allocations(1, 0, "anon_splat(**empty_hash#{block})") + + check_allocations(1, 1, "anon_splat(1, *empty_array, a: 2, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(1, *empty_array, **hash1, **empty_hash#{block})") + check_allocations(1, 1, "anon_splat(*array1, **empty_hash, a: 2#{block})") + check_allocations(1, 1, "anon_splat(*array1, **hash1, **empty_hash#{block})") + + unless defined?(RubyVM::YJIT.enabled?) && RubyVM::YJIT.enabled? + check_allocations(0, 0, "anon_splat(*array1, **nil#{block})") + check_allocations(1, 0, "anon_splat(*r2k_empty_array#{block})") + check_allocations(1, 1, "anon_splat(*r2k_array#{block})") + check_allocations(1, 0, "anon_splat(*r2k_empty_array1#{block})") + check_allocations(1, 1, "anon_splat(*r2k_array1#{block})") + end + RUBY + end + def test_anonymous_splat_and_anonymous_keyword_splat_parameters check_allocations(<<~RUBY) def self.anon_splat_and_anon_keyword_splat(*, **#{block}); end diff --git a/test/ruby/test_call.rb b/test/ruby/test_call.rb index ffbda1fdb9..13e6903a9b 100644 --- a/test/ruby/test_call.rb +++ b/test/ruby/test_call.rb @@ -374,6 +374,35 @@ class TestCall < Test::Unit::TestCase assert_equal({splat_modified: false}, b) end + def test_anon_splat_mutated_bug_21757 + args = [1, 2] + kw = {bug: true} + + def self.m(*); end + m(*args, bug: true) + assert_equal(2, args.length) + + proc = ->(*) { } + proc.(*args, bug: true) + assert_equal(2, args.length) + + def self.m2(*); end + m2(*args, **kw) + assert_equal(2, args.length) + + proc = ->(*) { } + proc.(*args, **kw) + assert_equal(2, args.length) + + def self.m3(*, **nil); end + assert_raise(ArgumentError) { m3(*args, bug: true) } + assert_equal(2, args.length) + + proc = ->(*, **nil) { } + assert_raise(ArgumentError) { proc.(*args, bug: true) } + assert_equal(2, args.length) + end + def test_kwsplat_block_eval_order def self.t(**kw, &b) [kw, b] end diff --git a/vm_args.c b/vm_args.c index 1303243fd3..dc14447e9e 100644 --- a/vm_args.c +++ b/vm_args.c @@ -639,12 +639,26 @@ setup_parameters_complex(rb_execution_context_t * const ec, const rb_iseq_t * co given_argc == ISEQ_BODY(iseq)->param.lead_num + (kw_flag ? 2 : 1) && !ISEQ_BODY(iseq)->param.flags.has_opt && !ISEQ_BODY(iseq)->param.flags.has_post && - !ISEQ_BODY(iseq)->param.flags.ruby2_keywords && - (!kw_flag || - !ISEQ_BODY(iseq)->param.flags.has_kw || - !ISEQ_BODY(iseq)->param.flags.has_kwrest || - !ISEQ_BODY(iseq)->param.flags.accepts_no_kwarg)) { - args->rest_dupped = true; + !ISEQ_BODY(iseq)->param.flags.ruby2_keywords) { + if (kw_flag) { + if (ISEQ_BODY(iseq)->param.flags.has_kw || + ISEQ_BODY(iseq)->param.flags.has_kwrest) { + args->rest_dupped = true; + } + else if (kw_flag & VM_CALL_KW_SPLAT) { + VALUE kw_hash = locals[args->argc - 1]; + if (kw_hash == Qnil || + (RB_TYPE_P(kw_hash, T_HASH) && RHASH_EMPTY_P(kw_hash))) { + args->rest_dupped = true; + } + } + + } + else if (!ISEQ_BODY(iseq)->param.flags.has_kw && + !ISEQ_BODY(iseq)->param.flags.has_kwrest && + !ISEQ_BODY(iseq)->param.flags.accepts_no_kwarg) { + args->rest_dupped = true; + } } }