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.
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
def first(limit = 1) chars[0..(limit - 1)].to_s end
In the Rails 2.0 version of
#chars returns an Array or Array-like object which can be subscripted with
#. The Ruby 1.8.7 version, by contrast, returns an
“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.
Here’s what I took away from this experience:
- Be wary of adding methods to core classes. What could possibly go wrong? More than you think.
- 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.
- Macs are shiny, but for industrial-strength development support, nothing beats a Debian-based system with APT.
- 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.