shiichan

Hidden for years! Cloudflare finds a bug in the hyper HTTP library

Hey everyone, it's Shiichan! Today I've got a story about how Cloudflare's engineers found a bug that had been hiding for years inside a famous open-source library that a huge amount of software is built on. And the thing that gave it away was a subtle but chilling failure: large images arriving cut off partway through. It reads like a detective novel, so let's dig in together!

Cloudflare Blog blog.cloudflare.com

What was announced?

This is an engineering-style post on the Cloudflare Blog. Because it's a technical blog, it walks through exactly how the team hunted the bug down in a lot of detail!

The star of the show is hyper, a go-to HTTP library written in Rust. Cloudflare's image transformation service, Images, runs on Rust and Workers, and it uses hyper to handle client connections.

In December 2025, the Images team rearchitected the binding. They replaced FL, the service that used to sit in the middle of the connection, with a new intermediary, connecting directly to an internal service on the same machine over a Unix socket. And then, within days of the rollout, the first customer report came in.

Why it matters

hyper is an HTTP library that an enormous amount of software is built on. And this bug wasn't something Cloudflare introduced. It had been lurking inside hyper for years, hiding across multiple major versions, and it only surfaced when the timing lined up just right. A nasty race condition.

Here's what the reported symptoms looked like:

  • Transformation requests failed intermittently, but only for larger images
  • The response came back as HTTP 200 (success) with no error logged
  • Yet the image data was truncated. A response that should have been around 2 MB arrived with only a few hundred KB

It looks successful, but data is actually missing. Because no error shows up, it's easy to miss, which is the scariest part. Stories about squashing these deep, foundational bugs are a great learning read for anyone who works with HTTP!

What changes

Cloudflare sent the fix, along with a test that reproduces the bug reliably, upstream to hyper (hyperium/hyper) as PR #4018, and it was merged! So once it lands in a future release, projects all over the world that use hyper can avoid the same pitfall.

Cloudflare itself deployed an internal fork with the patch applied and stabilized the Images binding. Giving the fix back to the open-source project like this is wonderful.

Dive Deep

Now for the main event! Let's follow along and see how the bug was tracked down.

First, reproduction. They built a Worker that mimicked the customer's nested setup, and in one run, 19 out of 25 requests failed (76 percent)! The cutoff point was consistently around 200 KB.

Next, version checking. They tried hyper 0.14, 1.7, and 1.8, and the bug appeared in every version. Local integration tests never reproduced it; the bug only showed up with real concurrency.

The breakthrough came from strace, a tool that records system calls into the kernel. Looking at a failing request, hyper wrote only part of the data and then immediately closed the connection.

sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808...", ...) = 219264
shutdown(42, SHUT_WR) = 0

The Content-Length says 14991808 bytes (about 14.9 MB), but only 219264 bytes were actually sent. The remaining ~14.8 MB never went out, and the connection was closed right away!

The root cause lived in hyper's HTTP/1 dispatch loop, inside dispatch.rs. It was this one line.

let _ = self.poll_flush(cx)?;

poll_flush() is the part that writes buffered data out to the socket, and when it hasn't finished writing everything, it returns Poll::Pending. But this code threw that return value away with let _ =, ignoring the important signal that said "I'm not done yet."

Here's the sequence that lost the data:

  • Images hands hyper the entire encoded response as a single in-memory block
  • hyper writes it to its internal buffer and assumes the send is done
  • poll_flush() writes to the socket, but once the socket buffer fills up around 219 KB, it returns Poll::Pending
  • the loop discards that Poll::Pending
  • the request has already been fully received, so no further reads are needed
  • the loop treats it as complete and moves on to close the connection
  • a SHUT_WR system call is issued
  • the client receives 219 KB and an EOF, when it was expecting 14.9 MB

The new architecture didn't create the bug. It consumed data a bit more slowly than FL, which made the socket buffer more likely to fill up during larger responses, exposing a timing-dependent flaw that had been hidden all along.

The fix waits for the flush to finish before closing the connection.

ready!(self.poll_flush(cx)?);

At first they tried checking the flush result inside the dispatch loop, but they landed on a more targeted fix that doesn't affect keepalive connection handling or the loop's performance. Clever, right!

Wrap-up

Let's recap today's key points.

  • While rearchitecting the Images binding, Cloudflare accidentally found a bug that had been hiding in the hyper HTTP library for years
  • The symptom was "HTTP 200, but large images arrive truncated," and with no error logged it was easy to miss
  • The root cause was discarding the Poll::Pending returned by poll_flush(), which closed the connection before the flush finished and lost data
  • The fix is a targeted, few-line change that waits for the flush to complete before closing
  • The fix and a reproduction test were merged into hyperium/hyper as PR #4018, giving it back to open source

If you write HTTP code in Rust, or you're wrestling with hard-to-reproduce 'data sometimes gets cut off' bugs, this is a really instructive read on how to hunt them down!