Rails upgrade led to Ruby bug
Have you ever seen Ruby bugs?
I recently did. Surprisingly, it happened during my Rails upgrade for a giant monolith.
My first impression was confusion.
I have never seen Ruby bugs before. But Ruby is another program that great engineers built. Nevertheless, it still could have some minor issues for edge cases.
So it happened to me, and I would like to share this short story on how I found it.
P.S. If you are considering upgrading the Rails version of your application, I encourage you to read my other short article with valuable advice that could help you avoid many issues I had.
In large code bases, times when you might want to change something’s behavior, are generally speaking higher. But in some rare cases, you might even want to monkey patch something only for specific contexts rather than globally. Having these strict requirements in mind, you may recall one of the least used features of Ruby: Refinements.
We used refinements to monkey-patch Kernel#puts
method for specific lexical contexts where the function has slightly different behavior.
P.S. Let’s take this case as it is.
In terms of the code, the below is a minimal example.
module Refinement
refine Kernel do
def puts(*args)
# We change the behavior
super(2)
end
end
end
module Environment
using Refinement
puts(1) # => 2
end
The code above works as expected with Rails 5.2.
However, it stops working with Rails 6.1.
How is this possible? How could Rails break Ruby functionality?
It turns out the bug is already there; Rails just exposes it. So the difference is that Rails 6 patches Kernel with two classes via prepend
.
Kernel.ancestors
=> [ActiveSupport::ForkTracker::CoreExtPrivate, ActiveSupport::ForkTracker::CoreExt, Kernel]
And this is the reason that causes this bug to be exposed. Here you have a minimal reproducible example.
module Prepender
end
module Refinement
refine Kernel do
def puts(*args)
super(2)
end
end
end
# The line below affects the behavior of Refinement
#
Kernel.prepend(Prepender)
#
# If it is commented out, then we have an expected output of 2
# Otherwise, refinement is ignored, and output is 1
module Environment
using Refinement
puts(1)
end
The bug has been found in Ruby 2.7.7. However, in Ruby 3, it seems it was fixed. I didn’t try to find a release note, but I feel it was a known case.