Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
The craziest ruby bug I've ever seen. I don't understand it. At all.

I've discovered a crazy bug that's really confusing me. I'm curious to hear if anyone can explain it.

Here's some code in foo.rb:

class Superclass
  unless ENV['NORMAL_METHOD_DEF']
    define_method :regex do
      /^(\d)$/
    end
  else
    def regex
      /^(\d)$/
    end
  end
end

class Subclass < Superclass
  unless ENV['FORCE_NIL_BLOCK']
    def self.override(name)
      define_method(name) { super() }
    end
 else
    def self.override(name)
      define_method(name) { super(&nil) }
    end
 end

  unless ENV['DONT_PASS_BLOCK']
    override(:regex) { }
  else
    override(:regex)
  end
end

puts "Subclass.new.regex returns a regular expression object:"
puts Subclass.new.regex.inspect
puts
puts "String#match(regex) returns a MatchData object:"
puts "8".match(/^(\d)$/).inspect
puts
puts "But somehow, when I combine these, I can get nil:"
puts "8".match(Subclass.new.regex).inspect
puts
puts "Unless I add a tap block that does nothing:"
puts "8".match(Subclass.new.regex.tap { |s| }).inspect

And the results of running it:

➜ ruby --version
ruby 1.9.3p327 (2012-11-10 revision 37606) [x86_64-darwin11.4.0]
➜ ruby foo.rb 
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
nil

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

For some odd reason, String#match(regex) is returning nil when it should be returning a MatchData object. Adding a no-op tap block fixes this, but I have no idea why.

It seems to be related to the fact that Superclass#regex was defined using define_method rather than def; note what happens when I run it with NORMAL_METHOD_DEF=1:

➜ NORMAL_METHOD_DEF=1 ruby foo.rb 
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

Also, the fact that I'm passing a block to override(:regex) seems to matter, too; if I run it with DONT_PASS_BLOCK=1 to avoid that, it fixes the problem again:

➜ DONT_PASS_BLOCK=1 ruby foo.rb
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

It appears that I can work around the bug by explicitly passing no block in the call to super using &nil:

➜ FORCE_NIL_BLOCK=1 ruby foo.rb
Subclass.new.regex returns a regular expression object:
/^(\d)$/

String#match(regex) returns a MatchData object:
#<MatchData "8" 1:"8">

But somehow, when I combine these, I can get nil:
#<MatchData "8" 1:"8">

Unless I add a tap block that does nothing:
#<MatchData "8" 1:"8">

I don't understand this. At all. I'm not passing a block to Subclass#regex.

Can anyone explain this?

peterc commented Mar 9, 2013

Just to make it more fun, Ruby 2.0.0 does not have the same problem! :) (But Ruby 1.9.3 even at p392 does.)

peterc commented Mar 9, 2013

The behavior of define_method changed a bit in Ruby 2.0.0 as it can now accept unbound methods as an argument, so I wonder if some refactoring took place there that resolved a bug (or introduced a new one). I'm having a little dig.

Owner

The behavior of define_method changed a bit in Ruby 2.0.0 as it can now accept unbound methods as an argument

I don't follow -- can you give an example showing what this means?

BTW, if you want the background on this, it started with this stackoverflow question which we took to this issue discussion.

peterc commented Mar 9, 2013

A slightly contrived example - it's sometimes called "method transplanting".

module Foo
  def add
    a + b
  end
end

class Bar < Struct.new(:a, :b)
  define_method :add, Foo.instance_method(:add)
end

b = Bar.new(10, 20)
p b.add  # => 30 in 2.0.0, TypeError in 1.9.3`

It's not really relevant to your problem (at least, I don't think it is) but you did ask ;-)

Owner

That's cool -- I've tried that before, and was sad that it didn't work.

peterc commented Mar 9, 2013

I've tried a few things to pin this down. Firstly, I simplified the example as far as possible to this:

class Superclass
  define_method(:regex) { /^(\d)$/ }
end

class Subclass < Superclass
  def self.override(name)
    define_method(name) { super() }
  end

  override(:regex) { }
end

puts RUBY_VERSION
p a = "8".match(Subclass.new.regex)
p b = "8".match(Subclass.new.regex.tap { |s| })
p a == b

As well as your tap technique, there are two other key ways to make it work. First is:

override(:regex)

.. so WITHOUT the empty block in the call to override.

And also:

define_method(name) { p super() }

So merely adding a "p" into Subclass#override's method definition.

I decided to try using set_trace_func to debug it. Intriguingly, if you enable tracing, the problem goes away!

set_trace_func(lambda { |*a| p a })

So this is certainly an odd one and given it has changed in Ruby 2.0, it's almost certainly a bug IMHO.

forforf commented Mar 9, 2013

Another tidbit .. instance_eval causes the problem to go away ... for example:

class Superclass
  define_method(:regex) {  self.instance_eval{  /^(\d)$/  }  } 
end
Owner

I decided to try using set_trace_func to debug it. Intriguingly, if you enable tracing, the problem goes away!

It's a heisenbug!

One other thing I just discovered; if you change the override(:regex) call to:

override(:regex) { :foo }

Then "8".match(Subclass.new.regex) returns :foo -- which doesn't make any sense to me. I'm thinking it might have to do with the fact that String#match accepts a block that affects the return value:

1.9.3-p327 :005 > "8".match(/(\d)/) { Hash.new }
 => {} 

Weird thing is, I tried changing the "8".match(Subclass.new.regex) call to a custom method that yields (to see if it can trigger the same odd behavior), and I get a LocalJumpError.

Owner

I decided to file a bug against the ruby bug tracker for this:

http://bugs.ruby-lang.org/issues/8059

Wow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment