Skip to main content
  1. Journal/

I Performed Necromancy on a 2010s Ruby Gem and It's Fine Now

Somewhere out there, a production system is still making SOAP calls. Not REST, not GraphQL — SOAP, with the angle brackets and the WSDL and everything. If that system is written in Ruby, there’s a decent chance it’s leaning on Soap4R-ng, the fork I’ve been keeping on life support since the original soap4r gem got left for dead somewhere around the Ruby 1.8 → 2.0 transition.

It just got release 2.1.0 — the first proper GitHub Release this thing has ever had. Naturally, it has almost nothing to do with SOAP.

RIP, little green badge
#

Back in the day this README had the full badge row: Travis CI and Code Climate, like every other Ruby gem of that era. Then the free tier for open-source projects got gutted, builds got flaky, and at some point the Travis badge quietly became a static image lying about a build that hadn’t actually run in years. Code Climate went a similar way — their free maintainability/coverage checks for open-source repos wound down as they pivoted to a paid product. Nobody did anything wrong here — that’s just what happened to a whole generation of OSS badges. Travis and Code Climate weren’t unique in that, they’re just the two this project happened to be standing on when the floor gave out.

What that actually meant in practice: for a long stretch, “does this still work on Ruby 1.8.7 through whatever’s current” was answered by vibes and a local test run before tagging a release, not by anything continuously checking. Fine for a while. Not fine forever, especially for a gem whose entire pitch is “works on the ancient Ruby you’re stuck with.” Slapping a new badge on wouldn’t have fixed anything — the actual fix was rebuilding the whole CI story from scratch. That’s what 2.1.0 really is.

The setup
#

The whole selling point of this gem is “it works on whatever ancient Ruby you’re stuck on.” Officially that’s Ruby 1.8.7 through 4.0.5, plus JRuby, which is an absurd range to promise and an even more absurd range to actually verify. Rebuilding CI from nothing meant picking how to provision all of it, and the first pass used ruby/setup-ruby straight on the GitHub Actions runner — normal, reasonable, does the job — except that runner kept quietly drifting out from under whatever I was testing locally: ubuntu-latest rolls forward out from under a pinned Ruby version, ruby-build doesn’t always have a binary for every Ruby × Ubuntu combo, and Ubuntu 22.04’s ICU-flavored libxml2 collided with libxml-ruby’s C extension in a way that flatly refused to reproduce on my own Debian box. Every so often something would pass in CI and faceplant locally, or the reverse, and the only honest explanation was a shrug.

For a compatibility library, “trust me, it probably works” is not a pitch. So 2.1.0 became: make local and CI run the exact same way, for every version in the matrix, so they can’t quietly drift apart. The obvious move — just run each job inside its Ruby image via the container: key — doesn’t actually work here: GitHub Actions injects its own Node.js runtime into container jobs to run actions/checkout, and half these official Ruby images are old enough (hello, Debian jessie-era glibc) that the injected Node binary can’t even execute. Every version through Ruby 2.7 died that way before a single test ran. So checkout happens on the normal runner, and a plain docker run handles the actual test run inside the target image — official Docker Hub ruby images for the current versions, custom Dockerfiles for the fossils.

And the fossils needed real custom recipes. Ruby 1.9.3 and 2.0.0 do have official images, but every tag was published under an ancient Docker manifest schema that current Docker/containerd flatly refuses to pull anymore — so those two get built from source via rbenv in a custom Dockerfile pinned to Debian bullseye, the last release still shipping the OpenSSL 1.1.1 and older GCC that ruby-build’s ancient-Ruby patches actually target. Ruby 1.8.7 never had an official image to begin with, and its OpenSSL bindings reach into struct internals that OpenSSL sealed off in 1.1.0, so a second custom Dockerfile builds it from source against a vendored OpenSSL 1.0.2u instead. All of it feels a little bit like restoring a vintage car so it can pass a modern emissions test — and the matrix is green everywhere it’s supposed to be, with a short, named list of exceptions (more on that below) instead of a blanket “should be fine.”

The two skeletons in the closet
#

Once environment drift was off the table, two real bugs showed up — both hiding in test teardown, both the kind of thing that had probably been silently costing CI time for years while everyone (me) just muttered “flaky test, rerun it.”

Bug one: a bunch of tests spin up a local WEBrick server and tear it down afterward by calling shutdown immediately followed by Thread#kill. Those two race. If kill lands before shutdown’s own async cleanup finishes, you can leak a bound port. On a shared runner, a leaked port means whatever test grabs that port next just hangs, for reasons that look completely unrelated to what it’s testing. 54 test files shared this exact teardown pattern. Fixed now — teardown waits with a bounded join and only nukes the thread as an actual last resort.

Bug two, my personal favorite: the SSL test suite spawned its test server through a shell (sh -c '...'), which means the server process was a grandchild, not a child, of the test process. Process.waitpid — which only knows about direct children — could never find it. So every SSL test run printed a baffled “no Child Process found to wait on” warning and then just sat there for 5 seconds, every single time, because something downstream had a timeout fallback quietly eating the failure. Removed the pointless shell hop. Instant tests, no more warning, no more mystery 5-second tax on every CI run.

Neither of those is a bug in the SOAP library itself. Both of them were actively teaching me to distrust my own test suite, which is the exact habit that lets real bugs hide in plain sight.

One related gremlin is still just managed, not solved. All five XML parsers (Ox, Nokogiri, libxml, Oga, REXML) run as separate test-suite invocations back-to-back inside the same long-lived container, sharing one hardcoded port. CI logs have caught that port staying stubbornly bound across that boundary, well past the test suite’s own retry budget — never once reproduced locally, never fully root-caused (best guess: a WEBrick/CGI subprocess not fully released). So CI just force-clears the port before every parser now, no questions asked. Not glamorous, but at least it’s honest about what’s understood versus what’s being politely worked around.

And a few things are allowed to be red, on purpose. Three Ruby patch versions share one known, non-soap4r quirk in how a test framework internal renders across that narrow range. The 1.8.7 job carries two documented environment issues that predate the language feature they’d need to pass. JRuby has its own short list of dependency quirks. All of it’s named in the README instead of hidden behind a green checkmark that doesn’t mean what it looks like it means.

The boring-but-necessary part
#

The rest is deprecation noise cleanup — frozen_string_literal warnings on Ruby 3.4+, a mutex_m/Thread::Mutex warning that fired on nearly every Logger call across a huge chunk of supported versions, and the “moved to a default gem” notices for logger/getoptlong/webrick on Ruby ≥ 3.4/4.0. None of it changes behavior. All of it means the CI log is readable again instead of a wall of noise hiding the one warning that actually matters.

Why anyone should care
#

Nobody’s excited about SOAP in 2026. But somewhere, some insurance carrier or healthcare system or EDI pipeline is still running it, because rewriting a working integration is expensive and “it still works” was a perfectly good reason to leave it alone. This release is for that system. It’s not glamorous. It’s just true now, on every Ruby it claims to support — with the small list of known exceptions labeled instead of “true, probably, I think.”

Full nerdy write-up — with more detail on the WEBrick race and the waitpid bug — is over on rubyjedi.com if you want the long version.