What are the hazards of Monkey Patching in Ruby? How you can create a Monkey Patch that you can share responsibly and safely with the Ruby community without causing bugs from forgetting to remove the patch.
We live in an imperfect world, which means sometimes you need to “get stuff out that solves the more immediate pain” to run cover while a more permanent fix gets put into place.
In Ruby, for better or for worse, we have a concept called “Monkey Patching”. It let’s you do stuff like this:
# Version 1.0 of Hello World
class Hello
def world
puts "Go away!"
end
end
# The patch
module HelloPatch
def world
puts "Hello world!"
end
end
Hello.new.world # => "Go away!"
# Apply the patch
Hello.prepend HelloPatch
Hello.new.world # => "Hello world!"
This makes it really easy to patch broken Ruby code—in this case we replaced the buggy “Go away!” greeting with the happier “Hello world!”
The problem
The problem is when the upstream software is patched and a new version goes out—often times the monkey patch can stay in place and cause unexpected bugs.
Imagine if version 2.0 of our Hello World library fixed the grumpy bug.
# Version 2.0 of Hello World: now with more friendliness!
class Hello
def world
puts "Howdy super friend!"
end
end
When we update to the new software a few months later and forgot about our patch, we’d be surprised to see "Hello world!"
instead of "Howdy super friend!"
.
How can we monkey patch responsibly?
Only apply the patch to specific versions of a library
The problem in the example above is one of version. We need a way to target our patches to specific versions of the “broken” software.
To look at a real world example, at Fly we have a problem where Redis servers have a 5 minute time-out. When the Redis connection times out, ActionCable doesn’t reconnect. Our first iteration of the fix? A monkey patch!
# config/initializers/action_cable.rb
require 'action_cable/subscription_adapter/redis'
module ActionCableRedisListenerPatch
private
def ensure_listener_running
@thread ||= Thread.new do
Thread.current.abort_on_exception = true
conn = @adapter.redis_connection_for_subscriptions
listen conn
rescue ::Redis::BaseConnectionError
@thread = @raw_client = nil
::ActionCable.server.restart
end
end
end
ActionCable::SubscriptionAdapter::Redis::Listener.prepend(ActionCableRedisListenerPatch)
The problem with putting monkey patches in the ./config/initializers/*.rb
directory is the same as before: a few months later we forget it’s there and when the newer version fixes it, the monkey patch could cause bugs that are hard to track down.
A better to handle project monkey patches like this is by putting something like this at the top of each patch:
if Rails.version > Gem::Version.new("7.0.4")
error "Check if https://github.com/rails/rails/pull/45478 is fixed"
end
When we upgrade Rails, this will blow up your CI or dev environment so a person on your team can check the PR and understand if the patch needs to be applied.
Releasing a community patch
How can we scale the approach above to work for an entire community of developers?
Fortunately we can use gemspec
‘s to manage this in a responsible way.
Since I know this problem currently effects actioncable
starting at 7.0.0
, and the current version of Action Cable, which is at 7.0.4
, I can specify that in my gemspec:
spec.add_dependency "actioncable", ">= 7.0", "<= 7.0.4"
When actionable 7.0.5
is released and the user runs bundle update
, nothing will happened because this dependency will keep actioncable
pegged at 7.0.4
.
That’s a good thing! Unless of course the developer wants the newer version of Rails. Since they forgot about the patch, they open up their Gemfile
and set to the latest version of Rails.
gem "rails", "7.0.5"
When they run bundle update
, they get an error:
Could not update to Rails 7.0.5 because the gem actioncable_redis-reconnectand depends on Rails 7.0 to 7.0.4
“WTF is that actioncable_redis-reconnect
gem!?” says the developer. So they go to https://gem.wtf/actioncable_redis-reconnect in their browser and get all the relevant context they need about that patch.
From this point they could do the following to resolve the issue.
### Open a PR to bump the actioncable dependency
Open a PR on the gem that bumps the actioncable dependency if the issue is still present in
actioncable
:- spec.add_dependency "actioncable", ">= 7.0", "<= 7.0.4" + spec.add_dependency "actioncable", ">= 7.0.4", "<= 7.0.5"
Bumping the version should only be done in the patch gem after the maintain has done the research to determine whether or not the monkey patch is still needed or is compatible with that release.
### Remove the monkey patch gem
Maybe the developer doesn’t care anymore, so they remove the monkey patch gem and they can upgrade to the latest version of Rails.
### Do nothing
You don’t always have to run the latest version of a framework, unless of course there’s a security patch that needs to be installed. In that case go back to 1.
The important thing is that the monkey patch was not allowed to persist quietly causing subtle bugs in production for years.
Deprecating the community patch
When the issue is fixed, the patch gem can finally be deprecated. How should that be done? Let’s say actioncable 7.0.6
fixes the bug. We’d change our gemspec to:
- spec.add_dependency "actioncable", ">= 7.0.4", "<= 7.0.5"
+ spec.add_dependency "actioncable", ">= 7.0.6"
Then we’d delete the monkey patch code and replace it with this message:
warn "The actioncable_redis-reconnect gem can be removed`
Eventually when developers update their gems, they’d make their way to the latest version of this patch gem, see the message, and remove the gem. Tada!
Conclusion
Ideally contributions are made timely and directly into the upstream repo, but for a lot of good reasons, that’s not always possible. Monkey patching can be a great workaround, but you always want to make sure you’re managing versions with monkey patches to avoid very-difficult-to-track-down bugs in the future.