6月22日、Cloudflareが「How we found a bug in the hyper HTTP library」と題した記事を公開した。RustのHTTPライブラリ「hyper」に長年潜んでいた競合状態のバグを、Cloudflareのエンジニアが6週間かけて特定・修正した過程を詳述したものだ。
hyperはRustの非同期HTTPライブラリとして広く使われており、tokioと組み合わせた高性能サーバーの基盤として多くのサービスで採用されている。CloudflareもImages処理基盤でhyperを活用しているが、今回のバグはアーキテクチャの改善をきっかけに何年も眠り続けていた欠陥が突然表面化した事例だ。
「200 OK」なのに画像が途切れる
バグの症状は奇妙だった。Images binding(Cloudflare WorkersからImages変換機能を直接呼び出せるAPI)を使った変換リクエストが断続的に失敗し、しかしHTTPレスポンスは**200 OK**を返し、ログにも一切エラーが出ない。2MBあるはずの画像が数百KBで切れて届く——そういう状態だ。
問題が表面化したのは2025年12月、Images bindingのアーキテクチャを刷新した直後だった。それまで内部中継サービス「FL」を経由していたリクエストパスを、同一マシン上のUnixソケットで直結する構成に変更した。ネットワークスタックのオーバーヘッドが減り、より高速になるはずの改善だった。ところが、この変更が眠っていたバグを呼び起こした。
6週間のデバッグ:何が起きていたか
エンジニアたちは疑わしい箇所を一つずつ潰していった。
- 再現スクリプトを作成し、25リクエストのうち19件が失敗することを確認。到達するデータ量(約200KB)がソケットバッファサイズに近いことが手がかりになった
- hyperのバージョンを0.14、1.7、1.8と総当たりしたが、全バージョンで発生。上流での修正は存在しなかった
- ローカル環境では再現しない。macOSでもDebian VMでも、どれだけ負荷をかけても失敗しなかった。本番環境で、実際の並行処理がある場合にのみ発生した
- 分散トレーシングで、切り詰めはImages serviceを出た時点で既に発生していることが判明。中継サービスは無実だった
アプリケーション層のデバッグツールはすべて「正常に送信した」と報告していた。システムの「自己申告」を信じる限り、バグは存在しないように見えた。
straceが暴いたもの
突破口になったのはstraceだ。Linuxのシステムコールを直接記録するツールで、Imagesサービスのプロセスにアタッチしてカーネルレベルの動作を観察した。
正常なリクエストのsyscallはこうなる:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
// ... ソケットバッファが空くたびに書き続ける ...
sendto(42, "...", 292352) = 292352
shutdown(42, SHUT_WR) = 0
バグが発生したリクエストはこうだ:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
shutdown(42, SHUT_WR) = 0
14.9MBのレスポンスのうち、219KBだけ送ってすぐシャットダウンしている。クライアントからの終了シグナルはない。Imagesサービスが自ら接続を切っていた——しかも「送り終わった」と信じながら。
なお、straceのフィルタを広げると、syscall割り込みのオーバーヘッドでタイミングがずれてバグが消えた。これ自体が、問題がタイミング依存の競合状態であることを強く示唆していた。
バグの正体:let _ = の4文字
原因はhyperのHTTP/1コネクション管理コード、dispatch.rs内の状態マシンにあった。簡略化すると:
fn poll_loop(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
loop {
let _ = self.poll_read(cx)?;
let _ = self.poll_write(cx)?;
let _ = self.poll_flush(cx)?; // ← ここ
if !self.conn.wants_read_again() {
return Poll::Ready(Ok(()));
}
}
}
問題はlet _ = self.poll_flush(cx)?の行だ。
Rustの非同期処理では、poll_flushのような関数はPoll::Ready(処理完了)かPoll::Pending(まだ処理中、後で再試行が必要)を返す。ところがlet _ = exprという記法はRustにおいて式の戻り値を意図的に破棄する構文であり、Poll::Pendingも例外なく捨てられる。フラッシュが完了していなくても、ループは次のイテレーションへ進み、wants_read_again()がfalseを返せばそのままシャットダウンへと進んでしまう。
ソケットの受信側がデータを消費するペースが少し遅いと、バッファが満杯になり、フラッシュがPoll::Pendingを返す。しかしその信号は無視され、接続が閉じられる。残りのデータはhyperの内部バッファに取り残されたまま消える。
FLが中継していた旧アーキテクチャでは、FLがデータを十分速く消費していたため、バッファが満杯になる前にフラッシュが完了していた。新しいUnixソケット直結構成では、受信ペースがわずかにずれるだけでこの競合が発生した。
修正はGitHubのPRに詳細があるが、要点はシンプルだ。poll_flushの戻り値をlet _ =で捨てるのをやめ、Poll::Pendingが返ってきたらループを中断して呼び出し元に制御を戻すよう変更した。変更行数はわずか4行だった。
なぜ長年見つからなかったのか
このバグが静的解析をすり抜け続けた理由は2つある。
1つ目は構文の問題だ。let _ =はRustで「意図的な無視」を示す慣用句であり、コンパイラの未使用変数警告を抑制するために日常的に使われる。Poll::Pendingを無視すること自体はコンパイルエラーにならず、clippyなどの静的解析ツールも「非同期のPollを捨てている」という文脈では警告を出さない(#[must_use]属性がPoll型に付いていない場合)。
2つ目はアーキテクチャの問題だ。旧構成のFLは意図せずバグを隠すバッファとして機能していた。「高速化のための改善」が、隠れていた欠陥を初めて観測可能な状態にした。アーキテクチャ変更が既存バグのトリガーになり得ること、そしてアプリケーション層が「正常」と報告する状況でも、straceによるカーネルレベルの観察が唯一の手がかりになり得ること——この事例はその両方を鮮明に示している。
詳細はHow we found a bug in the hyper HTTP libraryを参照していただきたい。