-
-
Notifications
You must be signed in to change notification settings - Fork 753
Timebomb Exceptions: Hangs when getting NameError/NoMethodError and an expensive #inspect #1631
Description
Example
class Something
def inspect
start = Time.now.strftime('%S').to_i
sleep(5)
seconds = Time.now.strftime('%S').to_i - start
seconds += 60 if seconds < 0
"#<Something that took #{seconds} seconds to inspect>"
end
end
describe 'Example of how confusing this could be' do
example 'tests chugging along happily before the issue' do
expect(true).to be_truthy
end
it "Here, we raise a Name or NoMethodError, whose #message depends on Something#inspect... "\
"but Ruby doesn't perform the inspect right now, it calls it later when the formatter "\
"tries to print the error message as part of the failure. Making it very difficult to "\
"figure out where it happened, and mak it look like an RSpec issue" do
Something.new.notamethod
end
example 'tests keep going just fine past the source of the issue' do
expect(true).to be_truthy
end
endExplanation of what is actually happening
Unlike if you or I were to create a NameError / NoMethodError, when the interpreter does it, it uses some sort of crazy ass black magic when it generates the "String" for the error's message (I think I can go into that in more detail if anyone actually cares). This "String" is lazy and doesn't get evaluated until you ask for it. Since it includes the inspection of the receiver that the method was called on (e.g. "zomg".notamethod # ~> NoMethodError: undefined methodnotamethod' for "zomg":String`), if the receiver's inspect method does anything crazy, it will trip up whatever code is looking at the message.
Crazy in this case is very common for certain use cases, e.g. the default inspect will show the inspection of the instance variables, so any object with a huge amount of data in an instance variable will have an incredibly expensive inspect method. If anything then calls a nonexistent method on that object, it will raise one of these Timebomb Exceptions.
When does the bomb finally go off? When RSpec tries to print the exception message out. It looks like the exception is stored on the Example (code) which is accessed by the FailedExampleNotification (code)This accessing doesn't happen until later in the :dump_failures event, which ultimately invokes failure_lines (code). And suddenly everything just halts.
In my example, it's due to a sleep statement, in the real-world, it's doing some unintentional big calculation, so the cpu is also pegged to 100%, and memory is ballooning.
What to do about it?
It's not too hard to check for (a timeout on exception.message), so it might be worth notifying the user that they have this issue.
At the very least, if the message was accessed as soon as it was raised, then the problem would occur near to the example that exposed the issue.
Why care?
This is the kind of thing that my students are hitting (I teach at @turingschool). They are not capable of this kind of debugging, it just shuts them down, turns them off to testing, and demotivates them. I need to be able to tell them what to do. And sure, I can narrow it down and figure out that they should override #inspect in 20 to 30 minutes, if it's early enough in the day, but that's a lot of time, I still can't tell them why, and I still have to do that every time an issue like this arises.
This debugging cost is spread out to anyone having this issue, not just me here and now. It's the sort of thing that I imagine many people have actually hit, but couldn't figure out why it was happening, wasted a lot of time, and ultimately just fixed the issue (temporarily via the NameError/NoMethodError or permanently via overriding inspect). Imagine if they hit it in a 4 hour test suite that only runs on CI, because their unit tests didn't uncover the issue. Now CI takes 6 hours, everyone goes home, running the failed integration test takes an hour (they're used to waiting, they don't realize something is wrong). The feedback cycle is slow enough already to mask that it has just ground to a halt. Something they could have fixed in an hour or two winds up taking over a day.