まさか何年も気づかれず!hyper HTTP ライブラリの隠れバグを Cloudflare が発見!
やっほー、しぃちゃんだよ!今日はね、世界中のソフトウェアが土台にしている有名な HTTP ライブラリに、何年も気づかれずに潜んでいたバグを Cloudflare のエンジニアさんが見つけちゃった、っていうお話だよ。しかも見つかったきっかけが「大きい画像が途中で切れる」という、地味だけどゾッとする不具合なの。探偵ものみたいでわくわくしちゃう!さっそく一緒に潜っていこう!
Cloudflare Blog
なにが発表されたの?
これは Cloudflare の Blog に投稿されたエンジニアリング系の記事だよ。技術寄りのブログだから、どうやってバグを追い詰めたのかがすごく詳しく書かれているの!
主役は hyper っていう Rust 製の定番 HTTP ライブラリ。Cloudflare の画像変換サービス Images は Rust と Workers で動いていて、その通信部分に hyper を使っているんだって。
2025 年 12 月、Images チームは binding の構成を作り直したの。今まで通信を仲介していた FL というサービスを新しい仲介に置き換えて、同じマシン上の内部サービスに Unix ソケットで直接つなぐようにしたんだよ。そしたらロールアウトから数日で、最初の顧客報告が届いちゃったの。
なぜ重要なの?
hyper は、ものすごくたくさんのソフトウェアが土台にしている HTTP ライブラリなの。しかもこのバグ、Cloudflare が新しく入れちゃったものじゃなくて、hyper の中に何年も前から潜んでいたものだったんだ。複数のメジャーバージョンにまたがって隠れていて、タイミングの条件がそろわないと表に出てこない、やっかいなレースコンディションだったんだよ。
報告された症状はこんな感じ:
- 画像変換のリクエストがときどき失敗する。でも失敗するのは大きい画像のときだけ
- レスポンスは HTTP 200(成功)で返ってきて、エラーログも出ない
- なのに画像データが途中で切れている。本来 2 MB くらいのはずが、数百 KB しか届かない
成功しているように見えて、実はデータが欠けている。エラーが出ないから気づきにくいのが、いちばんこわいところなの。こういう土台の不具合を一つずつ潰していく話は、HTTP を扱うエンジニアみんなの学びになるよ!
これで何が変わるの?
Cloudflare はこのバグの修正と、確実に再現できるテストを hyper 本家(hyperium/hyper)に PR #4018 として送って、マージされたよ!だから将来のリリースに取り込まれれば、hyper を使っている世界中のプロジェクトが同じ落とし穴を避けられるようになるの。
Cloudflare 自身は、修正を当てた内部フォークをデプロイして Images の構成を安定させたんだって。見つけた不具合をオープンソースにちゃんと還元してくれるの、すてきだよね。
深く潜ってみよう
ここからが本番!しぃちゃんと一緒に、どうやってバグを追い詰めたのか見ていこう。
まずは再現だよ。顧客の入れ子構成をまねた Worker を作ってみたら、あるときは 25 回中 19 回(76 パーセント)も失敗!しかも切れる位置はいつも 200 KB あたりで安定していたの。
次にバージョン確認。hyper の 0.14 と 1.7 と 1.8 で試したら、どのバージョンでもバグが出たんだって。ローカルの結合テストでは一度も再現しなくて、本物の同時アクセスがそろって初めて出るバグだったの。
決め手になったのは strace !カーネルへのシステムコールを記録するツールだよ。失敗したリクエストのログを見ると、途中まで書き込んで、すぐ接続を閉じていたんだ。
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808...", ...) = 219264
shutdown(42, SHUT_WR) = 0
Content-Length は 14991808 バイト(約 14.9 MB)と言っているのに、実際に送れたのは 219264 バイトだけ。残りの約 14.8 MB を送らずに、いきなり接続を閉じちゃっていたの!
原因は hyper の HTTP/1 のディスパッチループ、dispatch.rs の中にあったよ。こんな一行なの。
let _ = self.poll_flush(cx)?;
この poll_flush() は「バッファのデータをソケットに書き出す」処理なんだけど、まだ全部書き出せていないときは Poll::Pending を返すの。ところがこのコードは、その返り値を let _ = で捨てちゃっていた!「まだ終わってないよ」という大事な合図を無視していたんだよ。
データが欠ける流れを整理すると、こんな感じ:
- Images は変換後のレスポンス全体を、まるごと 1 つのメモリブロックとして hyper に渡す
- hyper は内部バッファに書き込んで、送信できたつもりになる
- poll_flush() がソケットへ書き出すけど、約 219 KB でソケットのバッファがいっぱいになると Poll::Pending を返す
- ループはその Poll::Pending を捨ててしまう
- リクエストはもう全部受け取っているので、読み直しも不要と判断される
- ループは「完了」と判断して、接続を閉じる処理へ進む
- SHUT_WR のシステムコールが発行される
- クライアントは 219 KB と EOF を受け取る。本当は 14.9 MB 欲しかったのに!
新しい構成がバグを生んだわけじゃないの。前の FL よりデータを消費するのが少し遅くて、大きいレスポンスのときにソケットのバッファがいっぱいになりやすくなったんだ。それで、ずっと隠れていたタイミング依存のバグが表に出てきちゃったんだね。
修正は、接続を閉じる処理のところで、閉じる前にちゃんと flush が終わるのを待つようにしたよ。
ready!(self.poll_flush(cx)?);
最初はディスパッチループ側で flush の結果をチェックする形も試したんだけど、keepalive の接続処理やループの性能に影響しない、もっとピンポイントな直し方にたどり着いたんだって。賢いよね!
まとめ
今日のポイントをおさらいするよ。
- Cloudflare が Images の binding を作り直したら、hyper HTTP ライブラリに何年も潜んでいたバグを偶然発見
- 症状は「HTTP 200 なのに大きい画像が途中で切れる」。エラーが出ないから気づきにくい
- 原因は poll_flush() が返す Poll::Pending を捨てていたこと。flush が未完了のまま接続を閉じてデータが欠けていた
- 修正は接続を閉じる前に flush 完了を待つ、たった数行のピンポイント対応
- 修正と再現テストは hyperium/hyper に PR #4018 としてマージされ、オープンソースに還元された
HTTP まわりを Rust で書いている人や、「たまにデータが切れる」系の再現しにくいバグに悩んでいる人には、追跡の進め方がめちゃくちゃ参考になる一本だよ!