From d52241bdaf4b1dc39aa5d35ecbba4d3f91fd37c9 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 6 Nov 2026 18:42:25 +0100 Subject: [PATCH] A &block-pass that is not a Proc raises TypeError, with MRI's two messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit language/send_spec.rb: pass=74 fail=4 -> pass=75 fail=3. S.makeproc(&Object.new) # rbgo: NoMethodError: undefined method 'to_proc' for an instance of Object # MRI: TypeError: no implicit conversion of Object into Proc toBlock reached to_proc through vm.send unconditionally, so an object with no to_proc at all surfaced the dispatch failure instead of a conversion failure. ## ⛔ The class was never NoMethodError, so this is not only a 4.0 move Measured on three engines: MRI 2.6.10 TypeError: wrong argument type Object (expected Proc) MRI 4.0.7 TypeError: no implicit conversion of Object into Proc rbgo NoMethodError: undefined method 'to_proc' ... 2.6 already answered TypeError. What 4.0 changed is the WORDING. The wrong class was a plain defect; only the two messages belong to #799. ## Bibliography vm_to_proc (vm_args.c at v4.0.9) looks the method up FIRST and branches, which is why there are two messages and not one: me = rb_callable_method_entry_with_refinements(CLASS_OF(proc), idTo_proc, NULL); if (me) { b = rb_vm_call0(GET_EC(), proc, idTo_proc, 0, NULL, me, RB_NO_KEYWORDS); } else { /* NOTE: calling method_missing */ b = rb_check_convert_type_with_id(proc, T_DATA, "Proc", idTo_proc); } rb_raise(rb_eTypeError, "can't convert %"PRIsVALUE" to Proc " "(%"PRIsVALUE"#to_proc gives %"PRIsVALUE")", ...) rb_raise(rb_eTypeError, "no implicit conversion of %s into Proc", ...) ## ⛔ The predicate is a METHOD ENTRY, not respond_to? The obvious spelling is `respondsToDynamic(v, "to_proc")`. It would have broken three cases that ALREADY WORKED, measured on 4.0.7 before choosing: a PRIVATE to_proc -> used, :priv to_proc via method_missing + respond_to_missing? -> used, :mm to_proc via method_missing ALONE -> used, :mmonly respond_to? is false for the first and the third. MRI uses rb_callable_method_entry (any visibility) and, failing that, rb_check_funcall, which calls method_missing when it is overridden and gives up when it is still BasicObject's. The guard mirrors that: findMethod, else a method_missing whose owner is no longer cBasicObject. All three are controls in the test. ## An upstream report I did NOT file ruby/spec expects "can't convert X into Proc" where MRI 4.0.9 formats "to Proc", so the corpus and the reference disagree. That is deliberate, not a bug: # CRuby < 4.1 has inconsistent coercion errors: # https://bugs.ruby-lang.org/issues/21864 # This matcher ignores the message on CRuby < 4.1 if RUBY_ENGINE == "ruby" and ruby_version_is ""..."4.1" message = nil CRuby 4.0's wording is known-wrong and fixed in 4.1; the check is waived for CRuby only. rbgo reports RUBY_ENGINE "ruby" and RUBY_VERSION "4.0.9", so the waiver covers it -- as spec_helper.rb:350 already records. Implementing 4.0.9's exact wording is therefore right, and there is nothing to report upstream. ## Measured cmd/oracle against /opt/homebrew/opt/ruby@4.0/bin/ruby: both probes AGREE byte for byte (9 rows). 10 table rows, 3 of them the respond_to? controls. Refs #799 Co-Authored-By: Claude Opus 5 --- internal/vm/block_pass_to_proc_test.go | 110 +++++++++++++++++++++++++ internal/vm/object_model.go | 33 +++++++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 internal/vm/block_pass_to_proc_test.go diff --git a/internal/vm/block_pass_to_proc_test.go b/internal/vm/block_pass_to_proc_test.go new file mode 100644 index 00000000..a3d847f0 --- /dev/null +++ b/internal/vm/block_pass_to_proc_test.go @@ -0,0 +1,110 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import "testing" + +const toProcPre = `class Specs + def makeproc(&b); b; end +end +S = Specs.new +def try + yield +rescue Exception => e + puts "#{e.class}: #{e.message}" +end +` + +// A &block-pass that is neither nil nor a Proc fails with a TypeError — NEVER a +// NoMethodError — and MRI has two distinct messages for the two ways it fails. +// vm_to_proc (vm_args.c at v4.0.9) looks the method up first: +// +// me = rb_callable_method_entry_with_refinements(CLASS_OF(proc), idTo_proc, NULL); +// if (me) { b = rb_vm_call0(…); } else { b = rb_check_convert_type_with_id(…); } +// +// ⛔ The class was never NoMethodError in MRI. 2.6.10 already answered +// `TypeError: wrong argument type Object (expected Proc)`; 4.0 changed the +// WORDING, not the class, so this is a plain defect and only the two messages +// belong to the 4.0 move. +// +// Every expectation was measured on +// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]` +// through scripts/conformance/rubyspec/cmd/oracle. +func TestABlockPassThatIsNotAProcFailsWithTypeError(t *testing.T) { + for _, tc := range []struct{ name, src, want string }{ + // ⛔ The decisive row: language/send_spec.rb "raises TypeError if block + // object isn't a Proc and doesn't respond to `to_proc`". This answered + // NoMethodError, which is the wrong class and not a wording question. + {"an object with no to_proc at all", + toProcPre + `try { S.makeproc(&Object.new) }`, + "TypeError: no implicit conversion of Object into Proc"}, + + // The other message: a to_proc that exists and answers something else. + {"a to_proc that returns a non-Proc names what it gave", + toProcPre + `class RawToProc + def to_proc; 42; end +end +try { S.makeproc(&RawToProc.new) }`, + "TypeError: can't convert RawToProc to Proc (RawToProc#to_proc gives Integer)"}, + + // ⛔ nil from to_proc is the same failure, not a missing block: the + // message names NilClass rather than falling back to "no implicit + // conversion", because a to_proc WAS found and did answer. + {"a to_proc that returns nil is still the convert message", + toProcPre + `class NilToProc + def to_proc; nil; end +end +try { S.makeproc(&NilToProc.new) }`, + "TypeError: can't convert NilToProc to Proc (NilToProc#to_proc gives NilClass)"}, + + // ⛔ The three rows the obvious predicate would have broken. MRI looks up + // a METHOD ENTRY, not respond_to?, so a private to_proc is used and a + // to_proc reachable only through method_missing is used — with or + // without respond_to_missing?. These already worked; gating the new + // refusal on respondsToDynamic would have made them raise. + {"a private to_proc is used", + toProcPre + `class PrivToProc + private def to_proc; -> { :priv }; end +end +p S.makeproc(&PrivToProc.new).call`, ":priv"}, + + {"a to_proc through method_missing is used", + toProcPre + `class MMToProc + def method_missing(n, *a) = n == :to_proc ? -> { :mm } : super + def respond_to_missing?(n, priv = false) = n == :to_proc || super +end +p S.makeproc(&MMToProc.new).call`, ":mm"}, + + {"a to_proc through method_missing without respond_to_missing? is used", + toProcPre + `class MMOnly + def method_missing(n, *a) = n == :to_proc ? -> { :mmonly } : super +end +p S.makeproc(&MMOnly.new).call`, ":mmonly"}, + + {"a private to_proc that answers a non-Proc still names the class", + toProcPre + `class PrivBad + private def to_proc; 42; end +end +try { S.makeproc(&PrivBad.new) }`, + "TypeError: can't convert PrivBad to Proc (PrivBad#to_proc gives Integer)"}, + + // ⛔ Controls: the two shapes that must NOT reach the conversion at all. + {"&nil means no block and raises nothing", + toProcPre + `p S.makeproc(&nil)`, "nil"}, + + {"a Symbol still converts", + toProcPre + `p S.makeproc(&:upcase).call("ab")`, `"AB"`}, + + {"a Proc is passed through untouched", + toProcPre + `l = -> { :given } +p S.makeproc(&l).call`, ":given"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := runSrc(t, tc.src); got != tc.want { + t.Errorf("%s\n got %q\nwant %q", tc.src, got, tc.want) + } + }) + } +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 4fce0249..b484a7f4 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -1747,6 +1747,31 @@ func (p *Proc) arityVal() int { // toBlock coerces a &block-pass value into a *Proc: nil for nil, the Proc // itself, else whatever its to_proc returns (which must be a Proc). +// +// ⛔ EVERY FAILURE HERE IS A TypeError, NEVER A NoMethodError, and MRI has two +// different messages for the two ways it can fail. vm_to_proc (vm_args.c at +// v4.0.9) looks the method up FIRST and branches on whether it found one: +// +// me = rb_callable_method_entry_with_refinements(CLASS_OF(proc), idTo_proc, NULL); +// if (me) { b = rb_vm_call0(…); } // call it, then check the result +// else { /* NOTE: calling method_missing */ +// b = rb_check_convert_type_with_id(proc, T_DATA, "Proc", idTo_proc); } +// +// rb_raise(rb_eTypeError, "can't convert %"PRIsVALUE" to Proc " +// "(%"PRIsVALUE"#to_proc gives %"PRIsVALUE")", …) +// rb_raise(rb_eTypeError, "no implicit conversion of %s into Proc", …) +// +// This used to reach `to_proc` through vm.send unconditionally, so an object +// with no to_proc at all raised NoMethodError — a class MRI has never used +// here: 2.6.10 already answered TypeError ("wrong argument type Object +// (expected Proc)"). What 4.0 changed is the WORDING, not the class. +// +// ⛔ The predicate is a method-entry lookup and NOT respond_to?. A to_proc +// reachable only through a custom method_missing works in MRI, with or without +// respond_to_missing? — measured on 4.0.7, and it already worked here, so +// gating on respondsToDynamic would have broken a case that was right. +// rb_check_funcall calls method_missing when it is overridden and gives up +// when it is still BasicObject's, which is what the owner check below asks. func (vm *VM) toBlock(v object.Value) *Proc { switch p := v.(type) { case object.Nil: @@ -1754,10 +1779,16 @@ func (vm *VM) toBlock(v object.Value) *Proc { case *Proc: return p default: + if vm.findMethod(v, "to_proc") == nil { + if mm := vm.findMethod(v, "method_missing"); mm == nil || mm.owner == vm.cBasicObject { + raise("TypeError", "no implicit conversion of %s into Proc", vm.classOf(v).name) + } + } conv := vm.send(v, "to_proc", nil, nil) cp, ok := conv.(*Proc) if !ok { - raise("TypeError", "no implicit conversion of %s into Proc", vm.classOf(v).name) + raise("TypeError", "can't convert %s to Proc (%s#to_proc gives %s)", + vm.classOf(v).name, vm.classOf(v).name, vm.classOf(conv).name) } return cp }