Whenever you drop into a debugging session, you're effectively executing an eval
against the binding at that spot in the code. Here's a simpler bit of code that recreates the behavior that's driving you nuts:
def make_head_explode
puts "== Proof bar isn't defined"
puts defined?(bar) # => nil
puts "== But WTF?! It shows up in eval"
eval(<<~RUBY)
puts defined?(bar) # => 'local-variable'
puts bar.inspect # => nil
RUBY
bar = 1
puts "\n== Proof bar is now defined"
puts defined?(bar) # => 'local-variable'
puts bar.inspect # => 1
end
When the method make_head_explode
is fed to the interpreter, it's compiled to YARV instructions, a local table, which stores information about the method's arguments and all local variables in the method, and a catch table that includes information about rescues within the method if present.
The root cause of this issue is that since you're compiling code dynamically at runtime with eval
, Ruby passes the local table, which includes an unset variable enry, to eval as well.
To start, let's use a use a very simple method that demonstrates the behavior we'd expect.
def foo_boom
foo # => NameError
foo = 1 # => 1
foo # => 1
end
We can inspect this by extracting the YARV byte code for the existing method with RubyVM::InstructionSequence.disasm(method)
. Note I'm going to ignore trace calls to keep the instructions tidy.
Output for RubyVM::InstructionSequence.disasm(method(:foo_boom))
less trace:
== disasm: #<ISeq:foo_boom@(irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] foo
0004 putself
0005 opt_send_without_block <callinfo!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
0008 pop
0011 putobject_OP_INT2FIX_O_1_C_
0012 setlocal_OP__WC__0 2
0016 getlocal_OP__WC__0 2
0020 leave ( 253)
Now let's walk through the trace.
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] foo
We can see here that YARV has identified we have the local variable foo
, and stored it in our local table at index [2]. If we had other local variables and arguments, they'd also appear in this table.
Next we have the instructions generated when we try to call foo
before its assigned:
0004 putself
0005 opt_send_without_block <callinfo!mid:foo, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
0008 pop
Let's dissect what happens here. Ruby compiles function calls for YARV according to the following pattern:
- Push receiver:
putself
, referring to top-level scope of function
- Push arguments: none here
- Call the method/function: function call (FCALL) to
foo
Next we have the instructions for setting at getting foo
once it becomes a global variable:
0008 pop
0011 putobject_OP_INT2FIX_O_1_C_
0012 setlocal_OP__WC__0 2
0016 getlocal_OP__WC__0 2
0020 leave ( 253)
Key takeaway: when YARV has the entire source code at hand, it knows when locals are defined and treats premature calls to local variables as FCALLs just as you'd expect.
Now let's look at a "misbehaving" version that uses eval
def bar_boom
eval 'bar' # => nil, but we'd expect an errror
bar = 1 # => 1
bar
end
Output for RubyVM::InstructionSequence.disasm(method(:bar_boom))
less trace:
== disasm: #<ISeq:bar_boom@(irb)>=======================================
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] bar
0004 putself
0005 putstring "bar"
0007 opt_send_without_block <callinfo!mid:eval, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0010 pop
0013 putobject_OP_INT2FIX_O_1_C_
0014 setlocal_OP__WC__0 2
0018 getlocal_OP__WC__0 2
0022 leave ( 264)
Again we see a local variable, bar
, in the locals table at index 2. We also have the following instructions for eval:
0004 putself
0005 putstring "bar"
0007 opt_send_without_block <callinfo!mid:eval, argc:1, FCALL|ARGS_SIMPLE>, <callcache>
0010 pop
Let's dissect what happens here:
- Push receiver: again
putself
, referring to top-level scope of function
- Push arguments: "bar"
- Call the method/function: function call (FCALL) to
eval
Afterward, we have the standard assignment to bar
that we'd expect.
0013 putobject_OP_INT2FIX_O_1_C_
0014 setlocal_OP__WC__0 2
0018 getlocal_OP__WC__0 2
0022 leave ( 264)
Had we not had eval
here, Ruby would have known to treat the call to bar
as a function call, which would have blown up as it did in our previous example. However, since eval
is dynamically evaluated and the instructions for its code won't be generated until runtime, the evaluation occurs in the context of the already determined instructions and local table, which holds the phantom bar
that you see. Unfortunately, at this stage, Ruby is unaware that bar
was initialized "below" the eval statement.
For a deeper dive, I'd recommend reading Ruby Under a Microscope and the Ruby Hacking Guide's section on Evaluation.