Why is a BasicObject a Range?

26 Jun 2016

I was upgrading a Rails 4.1 app from Ruby 2.2.3 to 2.3.1 and got an interesting error; from a debugger session:

(byebug) rand(20.minutes)
NoMethodError Exception: undefined method `begin' for 1200:Fixnum

Ben Sullivan has a nice writeup of some oddities around Rails 4.1's ActiveSupport::Duration and how it uses BasicObject (and David Stostik talks about it more here), so I won't repeat that here. The fix was simple, just:

rand(20.minutes.to_i)

But, it's still a puzzling difference. Taking Rails out of the equation, you can see the difference between 2.2.3 and 2.3.0:

$ ./ruby -e "puts RUBY_DESCRIPTION ; rand(BasicObject.new)"
ruby 2.2.3p173 (2015-08-18 revision 51636) [x86_64-darwin14]
-e:1:in `rand': undefined method `respond_to?' for #<BasicObject:0x007fa2d719bf78> (NoMethodError)
	from -e:1:in `<main>'

$ ./ruby -e "puts RUBY_DESCRIPTION ; rand(BasicObject.new)"
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin14]
-e:1:in `rand': undefined method `begin' for #<BasicObject:0x007fe0be4cb548> (NoMethodError)
	from -e:1:in `<main>'

It's pretty straightforward to trace this up to a certain point. In random.c InitVM_Random defines rand as a global Ruby function that maps to the C function rb_f_rand. That calls rand_range, which calls range_values, which calls rb_range_values which is defined in range.c. Then there are some duck-typing checks. There's a call to rb_respond_to which passes the argument (in this case, an instance of BasicObject) to rb_obj_respond_to to see if it responds to the message begin. And, surprisingly, it gets back a result of true! The same thing happens to the check for the existence of an end method. If either of those returned false, rb_range_values would have short-circuited with an early return. But they don't, so it doesn't, and so the runtime attempts to invoke begin on the object and a NoMethodError is raised.

rb_respond_to is defined in vm_method.c, and it delegates to rb_obj_respond_to which calls to vm_respond_to, which calls to method_entry_get see if that method is defined. That's the part that puzzled me - this snippet from vm_method.c:

const rb_method_entry_t *const me =	method_entry_get(klass, resid, &defined_class);
if (!me) return TRUE;

Seems like that should return -1, not TRUE, right?

As I was writing this up, poking around this code led me to git blame vm_method.c - and what do you know, it was just fixed on trunk! Jeff C reported this bug two months ago, Dan Barry has a nice comment there on what's going on, and then Nobu just fixed this yesterday with this changeset. Mystery solved!

Incidentally, here's another manifestation, this one from from Dan Barry's comment on the bug - same issue but different method, showing the difference between 2.2.3 and 2.3.0:

$ ruby -e "puts RUBY_DESCRIPTION ; Marshal.dump(BasicObject.new)"
ruby 2.2.3p173 (2015-08-18 revision 51636) [x86_64-darwin14]
-e:1:in `dump': undefined method `respond_to?' for #<BasicObject:0x007f99846b3f30> (NoMethodError)
	from -e:1:in `<main>'

$ ruby -e "puts RUBY_DESCRIPTION ; Marshal.dump(BasicObject.new)"
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin14]
-e:1:in `dump': undefined method `marshal_dump' for #<BasicObject:0x007fd7253b38b0> (NoMethodError)
	from -e:1:in `<main>'