Skip to content
Security Article

Your Flaky Test Might Be a Use-After-Free

Buildkite traced intermittent Rails test failures to heap corruption in Ruby's hiredis driver — code that also ran in production.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jul 22, 2026 · 5 min read
Your Flaky Test Might Be a Use-After-Free

Buildkite's engineering team published a postmortem this month that every team with a "flaky" tag in their test suite should sit with for a minute. A set of intermittently failing Rails feature tests — Selenium, ActionCable, Redis pub/sub, the usual suspects — turned out not to be a timing problem at all. It was a use-after-free in hiredis-client, the C driver that backs Ruby's redis-client gem. The same code was running in production.

That's the uncomfortable part. Nobody quarantined a race condition in a test helper; they were about to quarantine heap corruption.

The bug: pub/sub plus threads plus C

The redis-client gem — the transport underneath the mainline redis gem since version 5.0 — ships in two flavors: a pure-Ruby driver and hiredis-client, a native extension wrapping hiredis, the minimalist C client. The C driver exists for speed, and most Rails apps that care about Redis throughput reach for it without thinking twice.

The failure mode lived exactly where you'd predict if you've spent time around native extensions: the one place the client is genuinely multithreaded. ActionCable's Redis adapter runs a dedicated reader thread that sits in next_event waiting for pub/sub messages, while the main thread issues subscribe and unsubscribe commands. In hiredis-client, the read path also tried to flush the write buffer — a buffer the writing thread frees and reallocates. Two threads, one buffer, no lock that covers both: the reader ends up draining memory the writer already freed. AddressSanitizer's verdict, in the upstream report, was a textbook heap-use-after-free.

Buildkite's symptoms were what heap corruption always looks like from the outside: tests that fail a few percent of the time, occasional connection resets, then the tells — malloc: Double free of object and segfaults. The detail that gives the game away in their core dump analysis is a memmove with a size argument of 0x00a0ffffffffffb6, roughly 45 petabytes. A length field like that isn't a bug in your test; it's freed memory that got reused and reinterpreted.

Rian McGuire reported it upstream as redis-client issue #208 in July 2024, with a one-line fix merged the same day: the read path's flush was redundant — write already flushes — so deleting it closed the race. The fix shipped in redis-client 0.23.0, with a follow-up crash fix for subscriptions in 0.23.1. If you're on ActionCable with the hiredis driver and haven't bumped past 0.23.1, that's your action item from this piece.

The playbook is more valuable than the bug

The specific bug is fixed. What's worth stealing is the debugging chain, because almost none of it is Buildkite-specific.

First: they capture core dumps in CI. A Buildkite plugin detects core files after a step and uploads them as build artifacts. That's the whole trick, and it's the step most teams are missing. When a segfault happens once a week on an ephemeral CI agent, the evidence evaporates with the container — unless you've arranged for it not to. The equivalent in GitHub Actions is a few lines: set ulimit -c unlimited, point kernel.core_pattern somewhere sane, and actions/upload-artifact on failure.

Second: gdb on the core got them to "this is memory corruption in native code," which reframed the entire investigation. That reframing matters more than the stack trace itself. The moment you see an impossible allocation size, hypotheses about WebSocket timing and Selenium waits go in the bin.

Third: AddressSanitizer found the root cause in about 30 minutes of runtime, after a couple of hours building an instrumented Ruby. This is quietly a bigger deal than it sounds for Rubyists. ASan has been standard kit in C and C++ shops for a decade, but running a whole Ruby interpreter under it used to be miserable; the upstream report used Ruby 3.4 with ASan enabled, riding recent work in CRuby to make sanitizer builds viable. If your stack has native extensions — and every nontrivial Ruby, Python, or Node stack does — an ASan-built interpreter is now a realistic weapon rather than a research project.

Retry culture is a bug-hiding machine

Here's the editorial part, and there's some irony in a company that sells flaky-test detection handing us the evidence: the industry's default response to flakiness is engineered suppression. rspec-retry, @pytest.mark.flaky, CI-level auto-retries, quarantine queues. Those tools exist because most flakes really are timing noise, and burning engineer-days on every one of them is a bad trade.

But "flaky" is a description of a symptom, not a diagnosis, and we've let the tooling flatten that distinction. A test that fails with a stale-element error is a different species from a test whose process died. Segfaults, bus errors, malloc diagnostics, mysterious connection resets from a client library — these aren't flakes, they're crashes with a low reproduction rate, and a retry loop is precisely the wrong response because it converts a screaming signal into a green checkmark. Buildkite's tests had been failing intermittently within days of a Redis gem upgrade; a team with aggressive auto-retry might never have looked, and the use-after-free would have kept firing under production ActionCable load, silently or not.

The practical fix is to classify flakes by failure signature before they enter the retry pipeline. Exit codes from signals (anything 128+), SIGSEGV/SIGABRT in output, allocator error strings — route those to a human, never to quarantine. That's a grep in your CI pipeline, not a platform feature.

There's a broader lesson about where memory-unsafety actually lives in 2026. The Rust-versus-C discourse tends to fixate on operating systems and browsers, but the everyday exposure for most working developers is this: a "memory-safe" application language sitting on a stack of native extensions — hiredis bindings, JSON parsers, grpc, nokogiri — each one a small C codebase exercised by concurrency patterns its authors may never have tested. The pure-Ruby driver never had this bug. Buildkite's interim mitigation was simply to switch to it, trading some raw parsing speed for an entire vanished bug class. For a pub/sub connection that spends its life blocked on a socket, that trade is close to free — and it's worth asking, for each native extension in your Gemfile or requirements.txt, whether the speed you're buying is speed you can measure.

Flaky tests are the one place your system voluntarily confesses its rarest bugs. The least you can do is keep the body for the autopsy.

Sources & further reading

  1. How a flaky test exposed a Redis use-after-free — buildkite.engineering
  2. Thread safety issue with PubSub and hiredis (issue #208) — github.com
  3. Fix: remove redundant flush in HiredisConnection read path (PR #209) — github.com
  4. A flaky test exposed a Redis client use-after-free — news.ycombinator.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 6

Join the discussion

Sign in or create an account to comment and vote.

Maya Ito @opensource_maya · 3 weeks ago

this is the kind of thing that keeps me up at night with compiled extensions. did buildkite eventually patch hiredis-client, or are they still waiting on upstream? curious whether this was a known issue with thread safety in that particular version or a truly novel interaction they uncovered.

Larry Pike @legacy_larry · 3 weeks ago

use-after-free in production redis client and nobody caught it until flaky tests. that's the nightmare scenario right there.

Nina Petrova @night_owl_nina · 3 weeks ago

the scary part isn't just that it was in prod, it's that flaky tests masked it for who knows how long. if your redis pub/sub load is bursty or your test suite doesn't exercise threading enough, you could've been silently corrupting memory while everything looked fine. makes me wonder how many teams are sitting on similar time bombs in lesser-known C bindings.

Hal Mercer @greybeard_unix · 3 weeks ago

we had something similar with an old postgres driver that only surfaced under load—took us three years and a black friday outage to find it. the real lesson: flaky tests aren't a quality problem, they're a canary. soon as you see that pattern, you should be asking "what would happen if this happened at 100x the volume" instead of just adding a retry loop and moving on.

Dmitri Sokolov @ai_doomer_dmitri · 3 weeks ago

yeah, we hit this exact pattern last year during a migration to redis-client — except we caught ours through prod memory spikes on our metrics dashboards, not tests. the threading + pub/sub combo is genuinely treacherous because the failure modes are so indirect: you get intermittent 500s, worker restarts, connection drops, and nobody's brain goes "oh, my C extension is writing past the heap." @night_owl_nina is right that flaky tests are a symptom you should fear, not ignore.

Kat Sorensen @contrarian_kat · 3 weeks ago

yeah, that's the really sobering bit—tests saved buildkite but they only caught it because it was noisy enough. you could have gone months on subtle memory corruption if the symptom was just occasional worker restarts in prod.

Related Reading