The Trifecta of FAIL; or, how to patch Rails 2.0 for Ruby 1.8.7

It’s an oft-stated fact that most disasters result not from a single point of failure but from a combination of failures reinforcing each other. I wouldn’t term the problem I ran into last Friday a disaster, but it certainly cost me several hours of time trying to find a workaround.

Culprit #1: Rails

Rails’ ActiveSupport added a handy little method called #chars to the String class. In and of itself this doesn’t seem like such a bad thing, and a lot of other handy methods in ActiveSupport are built on top of #chars. However, as we’ll see, taking advantage of Ruby’s open classes to extend core types has a way of drawing the unwanted attention from the Law of Unintended Consequences.

Culprit #2: Ruby

It’s not set in stone anywhere, but there’s a fairly well accepted convention in open source projects that versions are divided into a major version, a minor version, and a tiny or patch version. New major versions indicate API-breaking changes. A new minor version may introduce new features, but existing code should continue to work as-is. And a new tiny version indicates that the API remains fixed; the only difference is that bugs have been fixed and security holes patched.

Ruby 1.8.7 is a minor release masquerading as a tiny release. Among the features backported into 1.8.7 from Ruby 1.9 is a new #chars attribute. Unfortunately, it is incompatible with the Rails 2.0 implementation of #chars. This, incidentally, is a prime example of one of the subtler ways that patching the core classes can bite you. Even if you are adding new methods rather than re-writing existing ones, the chances are good that someone else will have the same idea only with a slightly different implementation and semantics. Bang, incompatibility.

Culprit #3: MacPorts

We have an app which has not yet been ported to Rails 2.1. This, in itself, would not have been a problem; we can keep running it under Ruby 1.8.6 with Rails 2.0, no problem. However, I have a nasty habit of trying to keep my software up to date. So I run sudo port upgrade outdated periodically, and watch all the errors from unmaintained ports go scrolling across my terminal for 24 hours or so.

The last time I did this, one of the ports that did manage to build was Ruby. Version 1.8.7. The next time I ran our app, it of course promptly crashed.

This is the point at which I discovered something I hadn’t realized about MacPorts: it has no downgrade path. Coming from the world of Debian, Ubuntu, and apt-get, I just expected any package management system to handle the case where the user specifies an older version to be installed.

In fact, there’s a way to do it in MacPorts, but it’s painful.

Fail.

fail owned pwned pictures
So there I was with a broken app, no time in the iteration to upgrade it to Rails 2.1, and no easy way to get back to Ruby 1.8.6. Lame.

Rescue

After bitching and moaning on Twitter for awhile, I decided Bob helps those who help themselves, so I took a look at the crash backtrace I was getting. I traced it back to a line in vendor/rails/activesupport/lib/activesupport/core_ext/string/access.rb:

        def first(limit = 1)
          chars[0..(limit - 1)].to_s
        end

In the Rails 2.0 version of String#chars, #chars returns an Array or Array-like object which can be subscripted with #[]. The Ruby 1.8.7 version, by contrast, returns an Enumerable::Enumerator.

“That’s easy enough” thought I, and, fully expecting that patching this one issue would just reveal another incompatibility, and another, and another…, I changed the code to:

        def first(limit = 1)
          chars.to_a[0..(limit - 1)].to_s
        end

Lo and behold, the app worked perfectly.

Of course, YMMV. But as a quick kludge this one was surprisingly painless.

Lessons Learned

Here’s what I took away from this experience:

  1. Be wary of adding methods to core classes. What could possibly go wrong? More than you think.
  2. Patch releases should be true patch releases. It’s tempting to include a neat new feature as a bonus —
    again, what could possibly go wrong? Resist this urge.
  3. Macs are shiny, but for industrial-strength development support, nothing beats a Debian-based system with APT.
  4. Every now and then taking a clawhammer to vendor code is the shortest (short-term) way from point A to point B. Personally I prefer to either keep this kind of change local or, if necessary, version it with something like Piston, rather than maintaining it as a monkey-patch.

[ad#PostInline]