Monkey Patch Responsibly

Monkey patching Ruby code
Image by Annie Ruygt

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.

  1. ### 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.

  2. ### 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.

  3. ### 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.