Cloudflare が Rust 製 HTTP ライブラリ「hyper」のバグを発見した方法
Cloudflare は、Rust の HTTP ライブラリ「hyper」における特定の条件下でのみ発生する微妙な競合状態バグを発見・修正し、大規模画像処理時のデータ欠損問題を解消した。
キーポイント
隠れたバグの発見と症状
Workers の Images バインディング再設計後、特定の条件下(大規模画像)でのみ断続的に発生する、レスポンスが途中で切断される(200 OK だがデータ欠損)という不可視なバグが発見された。
根本原因の特定
6 週間の調査の結果、hyper ライブラリ内のソケット接続とバッファ管理における競合状態(race condition)が原因であることが判明した。
簡潔な修正と解決
複雑なシステム上の問題であったにもかかわらず、最終的に 4 行のコード変更のみでバグを完全に修正し、サービスの安定性を回復させた。
Hyper のバッファフラッシュと接続シャットダウンの競合
読み込み側が遅延すると Hyper のアウトバウンドバッファが満杯になり、書き込み待ちが発生するが、この状態で接続終了(shutdown)を通知してしまう不具合の原因となった。
FL 中間サービスの置き換えによるパフォーマンス向上
2025 年 12 月に FL サービスから同一マシンの内部ワーカーバインディングへ移行し、Unix ソケット経由で直接接続することでネットワークスタックのオーバーヘッドを排除した。
ネストされた画像処理パイプラインでの不具合発覚
外部から 2 層の画像処理(R2 から合成し、さらに URL インターフェースで圧縮)を行う非標準的な顧客設定において、バグが初めて報告された。
バグの根本原因と症状
内部パイプライン(トランスフォームバインディング)がレスポンスを切り捨てた結果、外部パイプラインで「Content-Length ヘッダーと実際のボディサイズ不一致」のエラーが発生した。
影響分析・編集コメントを表示
影響分析
この事例は、高性能な Rust ライブラリを使用するシステムにおいてさえ、特定のタイミングやデータサイズ条件下で発生する競合状態バグが、一見正常に見える形で重大なデータ損失を引き起こすリスクを浮き彫りにしています。開発者にとっては、エラーコードやログに依存せず、実際のデータ整合性を厳密に検証する重要性を再認識させるケーススタディとなります。
編集コメント
4 行のコードで解決したという事実は、バグ発見プロセスの難易度の高さを逆説的に示しており、複雑な分散システムにおけるライブラリの挙動理解がいかに重要かを痛感させる記事です。
Workers で Rust によって構築された Images サービスは、Cloudflare のエッジネットワーク上のすべてのマシン上で動作しています。クライアント接続を処理するために、私たちは Rust 向けのオープンソース HTTP ライブラリである hyper を使用しています。
昨年、リモート画像の処理のために Workers でカスタムかつプログラム可能なワークフローを可能にするため、Images バインディングを導入しました。2025 年末には、Workers ランタイムと Images サービス間のより直接的なローカル接続を提供するために、バインディングのアーキテクチャを再設計しました。
ロールアウト直後、バインディングからの変換リクエストが失敗するという報告を受けました。ただし、これは断続的に発生し、かつ大きな画像に対してのみでした。さらに奇妙なことに、これらのリクエストに対するレスポンスはエラーログなしで 200 ステータスを返していました。画像データが単に途中で切れてしまっているのです。本来 2 メガバイトであるはずのレスポンスが、数百キロバイトしか届かないという状況です。
私たちは、hyper ライブラリ内で特定の条件下でのみ発生するほぼ見えないバグ(競合状態)を追跡するのに 6 週間を費やしました。このバグは、Images バインディングが処理された画像データをクライアントに返す方法に影響を与えていました。最終的に、修正にはコード 4 行で十分でした。
Hops, handoffs, and hyper
Cloudflare で開発を行う際、開発者は Workers からバインディングを通じてアクセス可能な一連のプラットフォームサービスからフルスタックアプリケーションを構築します。バインディングは、計算、ストレージ、AI 推論、メディア処理など、Developer Platform 上のリソースへの直接 API を提供します。
画像バインディングは、画像の最適化と配信を分離します。これにより、HTTP レスポンスとして出力を返す必要なく、画像のトランスコード、合成、または操作を行うことができます。また、URL インターフェースによって強制される固定された順序に従うのではなく、最適化パラメータを任意の順序で適用することも可能です。ここでは、ワーカーが画像データを直接 Images API に渡して操作をチェーンし、処理結果をストリームとして受け取ることができます:
const result = await env.IMAGES
.input(image)
.transform({ width: 800, rotate: 90 })
.output({ format: "image/avif" });
return result.response();
高レベルでは、これが画像データが当社のさまざまなサービス間を移動する仕組みです:
image
パイプは、仲介者と Images の間のソケット接続を表しており、データはカーネルのバッファを介して一方のプロセスから次のプロセスへ引き渡されます。
バインディングは、Workers ランタイムによって管理されるソケット接続を通じて Images と通信します。ソケット接続とは、2 つのプロセス間の通信チャネルです。ソケットの各端にはオペレーティングシステムのカーネルによって管理されるバッファがあり、これらのバッファは一時的な保持領域であり、データが一方側から書き込まれた後、他方側で読み取られるまでの間、そこにデータが滞留します。
Hyper は、Images サービス側で接続を管理し、ソケットから着信リクエストを読み取り、応答をそのソケットに書き戻します。
リクエストが Images バインディングを使用する場合、Images サービスは入力を読み取り、要求された最適化操作を実行した上で結果をエンコードします。その後、エンコードされた画像全体を単一のメモリ内ブロックとして Hyper に渡します。
Hyper はこの応答データを自身の内部バッファに書き込みます。この時点で、Hyper は必要なすべてのバイトを取得したため、エンコーディング作業は完了とみなされます。次のステップは、内部バッファの内容をソケットの出力バッファへフラッシュし、データを Images サービスから向こう側の仲介者へ移動させることです。
向こう側のリーダーが高速であれば、Hyper は一度のパスで全てのデータをフラッシュできます。リーダーが到着したデータと同じ速度で消費しているため、出力バッファには空きスペースがあるからです。すべてのデータを送信した後、Hyper はソケットに対してシャットダウンを発行し、接続が完了し、これ以上データは書き込まれないことをシグナルします。しかし、リーダーが遅い場合(数ミリ秒遅れでも)、出力バッファがいっぱいになり、Hyper は書き込みを続けるために空きスペースができるまで待機する必要があります。
ローカル
を取る
Cloudflare のネットワーク上のすべての着信トラフィックは、セキュリティ機能とパフォーマンス機能を実行し、リクエストを適切なバックエンドにルーティングする内部仲介サービスである FL を経由します。バインディングを最初にリリースした際、画像データは Workers ランタイムから FL を経て Images サービスへ流れていました。
このパスは初期リリースには自然な選択であり、URL インターフェースと同じアーキテクチャに従っています。しかし時が経つにつれ、FL とのこの結合が制約となりました:バインディングに対するあらゆる変更は、FL のリリースサイクルに従わなければなりませんでした。
2025 年 12 月、Images チームは FL を置き換え、同じマシン上で動作する内部ワーカーバインディングである新しい仲介サービスを採用しました。元のアーキテクチャではデータはネットワークソケットを介して FL を経由して移動しましたが、このパスには DNS ルックアップやルーティングなど、FL の完全な処理パイプラインに伴うオーバーヘッドが含まれていました。
内部バインディングはこれらを Unix ソケット (Unix sockets) に置き換え、同じマシン上のサービスに直接接続することで FL とネットワークスタックのオーバーヘッドをバイパスしました。これにより、Images へのリクエストパスが高速化され、チームはバインディングのリリースに対して独立した制御権を得ました。
ロールアウトから数日以内に、最初の顧客からの報告が届きました。
200 OK (not OK)
トラブルの最初の兆候は、非標準的なセットアップを持つ顧客から寄せられました:画像処理の 2 つのレイヤーがあり、一方のパイプラインがもう一方の中にネストされていました。
まず、ワーカーは Images バインディングを使用して、R2 から複数の大規模なソース画像(JPEG 背景に PNG オーバーレイ層)を合成し、単一の結合された JPEG に変換しました。次に、URL インターフェースを通じて結果をさらに圧縮、トランスコード、リサイズしました。

バグは、レスポンスが外側のパイプラインに到達する前に切り捨てられていた内側パイプラインの戻りパスで発生しました。
内側パイプライン(トランスフォームバインディング)は合成処理を担当し、外側パイプライン(トランスフォーム URL)はスケーリングやフォーマット変換などの配信最適化を担当していました。この階層型アプローチにより、内側パイプラインが静かに切り捨てられたレスポンスを返した際、唯一表示されるエラーは一つ上のレベルで発生しました:
error reading a body from connection: end of file before message length reached
外側パイプラインは内側から HTTP 200 を受け取り、数メガバイトのデータがあることを示す Content-Length ヘッダーを含んでいました。しかし、実際のボディはその一部に過ぎませんでした。あるリクエストでは、期待された 3.3 MB のうち約 200 KB しか到着しませんでした。エラーは外側パイプラインで表面化しましたが、切り捨ての原因はバインディング、仲介サービス、Images サービス、あるいはその間のどこかにあった可能性があります。
ブラウザが切り捨てられた画像を受信すると、その結果は視覚的に確認できます。フォーマットによっては、画像が部分的にレンダリングされる(例えば下半分が欠落しているか灰色になる)か、完全にデコードできずに破損した画像として表示されます。
暗闇でのデバッグ
ここから、リクエストパスを内側に向かって調査し、各レイヤーをテストして切り捨てが発生している箇所を特定しました。これらの取り組みの一部は行き止まりにぶつかりましたが、他の一部は手がかりを残し、検索範囲を絞り込みました:
再現環境の構築。顧客のネストされた設定を模倣するワーカーを構築し、バインディングだけでバグをトリガーできるまでレイヤーを一つずつ削除しました。小さなスクリプトを使ってリクエストを一括で送信できるようになりました。初期の実行の一つでは、25 件のうち 19 件が失敗しました。実際に到達したデータ量(約 200 KB)は、本番環境のソケットバッファサイズと不自然に近かったことから、問題が顧客の設定に起因するものではないことが確認され、オンデマンドでバグを再現できる信頼性の高い方法が得られました。
タイムアウトの調査。当初、切り捨てがタイムアウト動作(つまり、時間制限後に接続が閉じられること)に関連しているのではないかと疑いました。しかし、この仮説は成立しませんでした。なぜなら、切り捨てはリクエストの継続時間と相関していなかったからです。
hyper のバージョンを更新する。バグが最初に報告された際、私たちは 0.14.x を実行しており、最新の hyper バージョンは約 1.8.x であった。最も明白な答え(そして最も簡単な解決策)が正解である可能性も考慮し、hyper バージョン 0.14、1.7、1.8 のすべてでテストを行った。しかし、どのバージョンでもバグが発生したため、上位プロジェクト側での修正は存在しないことが示された。
ローカルでの再現試行。macOS と Debian VM でローカルの統合テストを実行した。かなりの負荷がかかっている状況下であっても、ローカルからのリクエストが一切失敗を引き起こすことはなかった。バインドソケットに対して直接 curl リクエストを送ったり、キャプチャされたリプレイを行ったりしても、常に正常に動作しているように見えた。このバグは、実際の並行処理が発生し、ソケットの向こう側に本物の Workers ランタイムクライアントが存在する、完全なプロダクションパスでのみ発生した。これにより、ランタイム自体が原因ではないかと疑われるようになった。
Workers ランタイムの可能性を除外する。バインドソケットを通じて Images と通信するために Workers ランタイムが使用する HTTP クライアント(HTTP client)を検討した。接続の両側からのトレースにおいて、予期せぬクローズや早期終了を示唆するシステムコールは一切確認されなかった。クライアントは正しく動作しており、他の複数のサービスも同じクライアントを問題なく使用していることが観察された。
分散トレーシング。リクエストのトレースをエンドツーエンドで調査した結果、本文が切り捨てられた状態が、顧客環境における外側のトランスフォーメーションレイヤーに到達する以前から既に存在していたことが確認された。これにより問題は内部パイプライン、すなわち Images サービスを経由するバインドパスに絞り込まれた。
仲介サービスのインストゥルメンテーション。レスポンスデータを転送する前にボディサイズを測定するために、仲介サービスにインストゥルメンテーションを追加しました。データが Images サービスから離れる時点ですでにボディは切り捨てられていたため、仲介サービスは原因ではないと判断されました。
Images サービス内でのより詳細なトレーシング。サービスレベルではリクエストが処理され、画像は適切にエンコードされ、レスポンスは HTTP 200 で送信されていました。
一貫して確認できた信号は、このバグがタイミング依存性を持っていたことです:本番環境のパスで実際の並行処理が発生している場合のみ、かつ大きな画像に対してのみ発生しました。
真実の核
アプリケーションレベルのデバッグツールが示したのは、システムが自分で行っていると思っていることだけでした。しかし、システムの観点ではすべて正常でした:トレーシングはレスポンスが送信されたことを示し、ログにはエラーはなく、Images サービスはすべてのリクエストに対して 200 を返していました。
システムが実際に何を行っているかを確認するために、私たちは Images サービスに strace を取り付けました。strace はプロセスがカーネルに対して行うシステムコールを記録するものであり、これによりどのバイトがいつ書き込まれたか、シャットダウンがいつ呼び出されたか、クライアントから終了シグナルが送られたかどうかを正確に把握できます。
トレースのセットアップは繊細な作業でした。strace はシステムコールが発生する瞬間にそれをインターセプトして動作するため、各コールにわずかなタイミングオーバーヘッドが生じます。対象となるシステムコールを狭い範囲に絞り込むことで、このオーバーヘッドを最小限に抑えることができました。しかしフィルタを広げるとプロセスが十分に遅くなり、フラッシュとシャットダウンチェックの間のタイミングがずれてしまい、バグが完全に消えてしまうのです。これだけでも、問題がタイミング依存性を持っているという私たちの仮説を裏付けるものでした。
再現用ワーカーを使用してバグをトリガーし、成功するリクエストと失敗するリクエストの間でシステムコールの出力を比較しました。
成功するリクエストでは、レスポンスはソケットバッファの許容範囲に応じてチャンク単位で書き込まれ、すべてのデータを送信した後にのみ shutdown が呼び出されます。例えば、以下のような様子になります:
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.9 MB の応答のうち、約 219 KB しか送信されませんでした。残りの約 14.8 MB に及ぶ画像データは hyper の内部バッファーから決して外れることなく、書き込みとシャットダウンの間にはクライアントからの終了信号もありませんでした。代わりに、Images サービスは自身が完了したと真に信じて、接続を先回りしてシャットダウンしました。
失敗したリクエストにより、このバグが間欠的に発生する競合状態(race condition)であることが確認されました。リクエストが成功するか失敗するかは、フラッシュ操作とシャットダウン操作が重複するかどうかにかかっており、これはリクエストごとに異なります。hyper が接続の終了を決定した瞬間にバッファーがいまだ満杯だった場合、データが失われます。
image
リーダーが hyper の書き込みよりも遅く消費する場合、アウトバウンドバッファーがいっぱいになります。hyper がバッファーが空になる前に接続をシャットダウンすると、応答の一部のみが仲介者に到達します。この不完全なデータは Workers ランタイムとクライアントへ逆方向に転送されます。
12 月の再設計がこのバグを導入したわけではありません。このバグは hyper で何年も、複数の主要バージョンにわたって存在していました。しかし、新しい仲介者によって、ソケットの応答側でデータを読み取る主体が変更されました。私たちの仮説では、以前の仲介者である FL はデータを十分に高速に消費していたため、応答中にソケットバッファが満たされることは稀でした。一方、新しい読み取り手は、より大きな応答時にバッファがたまることがある速度でデータを読み込んでいました。
他のすべての処理を高速化する改善によって導入された、わずか数ミリ秒のバックプレッシャー(逆圧)こそが、これまで平然と隠れていた欠陥を表面化させるのに十分な要因でした。
dispatch ループ内
Hyper の HTTP/1 接続ライフサイクルは、dispatch.rs というファイル内の状態機械によって駆動されています。このループはリクエストの読み取り、応答の書き込み、書き込みバッファをソケットへフラッシュすること、そしてシャットダウンするタイミングを決定します。簡略化すると以下のようになります。
fn poll_loop(&mut self, cx: &mut Context) -> Poll> {
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(()));
}
}
}
より正確には、poll_flush の直前の let _ がバグが存在する場所です。
Rust において、let _ = expr は式の結果(Poll::Pending を含む)を破棄します。これはフラッシュがまだ完了していないことを示すシグナルです。フラッシュにはまだ数メガバイトのデータがバッファに残っている可能性がありますが、ループ側はそのことに気づきません。
リクエストが失敗した場合、以下の順序でイベントが発生します:
画像サービスが画像のエンコーディングを完了し、レスポンス全体を単一のメモリブロックとして hyper に渡します。
hyper はそのブロックを内部バッファに書き込み、書き込み状態を Writing::Closed としてマークします。エンコーディングの観点からは作業は完了しており、これ以上エンコードすべきものは残っていません。
hyper は poll_flush を呼び出して、バッファ内のデータをソケットへ転送しようとします。先ほどの例では、ソケットは約 219 KB のデータを受け入れましたが、残りの約 14.8 MB は hyper のバッファ内に残ったままです。ソケットがいっぱいになっているため、カーネルから Poll::Pending が返されます。
poll_loop は let _ を使って Poll::Pending を破棄します。
次に wants_read_again() をチェックしますが、リクエスト全体はすでに受信済みであるため、これは false を返します。
poll_loop は Poll::Ready(Ok(())) を返し、ループが完了したことをシグナルとして伝えます。ただし、フラッシュはまだ完了していません。
poll_shutdown() が発火し、SHUT_WR システムコールが発行されます。
クライアントは 219 KB のデータと、接続が閉じられたことを示す EOF(end-of-file)を受信しますが、実際には 14.9 MB を期待していました。
2 つ目のステップでは、hyper は応答ボディがバッファリングされた瞬間(つまりエンコーディングが完了した瞬間)に書き込み操作を完了したとマークしますが、実際にフラッシュされた時点ではありません。通常はフラッシュが単一のパスで完了するため、この区別は目に見えません。稀にソケットバッファがいっぱいになった場合、hyper が待たなくてもフラッシュは待つ必要があります。バイトはまだ hyper のバッファ内にあり、ソケットへのフラッシュを待っています。hyper はそのデータがまだバッファ内にある状態で接続のシャットダウンを進めます。
これはまた、curl がなぜこのバグを発見しなかったかを説明しています。curl はデータ到着速度で読み取ります:ソケットバッファがいっぱいになることはなく、フラッシュは常に即座に完了し、無視された戻り値は無害です。数ミリ秒間時々停止するリーダーを持つ本番環境のパスだけが、ちょうど悪い瞬間にバッファが満たされる構成でした。
フラッシュを忘れないでください
調査から数週間後、修正自体は概念的には単純なものでした。hyper は次に進む前にフラッシュが実際に完了したかどうかを確認する必要がありました。
私たちの再現ワーカーはバグが存在することを確認しましたが、特定の要求がなぜ失敗したのかを教えることはできませんでした。修正を書く前に、hyper 内の正確なソケット条件を引き起こすことができるテストが必要でした。
バグをトリガーする条件は把握していました:1 チャンクのデータを受け取った後にブロックするソケットです。制御されたシナリオでテストするために、TCP ストリームのカスタムラッパーを作成し、完全なソケットバッファを模擬しました。このラッパーは最初の書き込みで 8 KB を受け取り、その後のすべての書き込みで Poll::Pending を返すことで、バッファの排水を停止したリーダーを模倣します。
テストでは、この制約されたソケットを通じて 500 KB のレスポンスを送信し、492 KB がまだバッファリングされている間に hyper が shutdown を呼び出すかどうかを確認しました。修正がない場合、それは呼び出されました。修正を加えた場合、待機しました。
当初、hyper のディスパッチループにこの修正を適用しました。poll_flush の結果を破棄するのではなく、フラッシュが実際に完了したかどうかをチェックします:
let flush_result = self.poll_flush(cx)?;
if flush_result.is_pending() {
return Poll::Pending;
}
if !self.conn.wants_read_again() {
return Poll::Ready(Ok(()));
}
フラッシュが完了していない場合、ループは非同期ランタイムに Poll::Pending を返します。ランタイムはソケットが書き込み可能になるまで待機し、タスクを再起動してフラッシュを続行させます。接続はすべてのデータが送信された後にのみシャットダウンされます。
この修正を展開した際、すべてのバイトが書き込まれ、バッファが実際に空になった後にのみ shutdown が呼び出されることを確認しました。最初の報告を行った顧客も、問題が消えたことを確認しました。
初期の解決策は機能していましたが、ディスパッチループが修正を施すべき適切な場所ではありませんでした。Poll::Pending を早期に返すことは、同じ接続上での他の操作を遅くする可能性があります。これは読み込みのポーリング頻度を低下させることで、意図しないバックプレッシャーを引き起こすからです。また、キープアライブ接続(1 つの接続で複数のリクエストが順次処理される状態)も正しく扱えません。これらの接続は、前のレスポンスがまだフラッシュされている間でも再利用可能であるべきです。これらの問題は、私たちが扱う特定のサービス(キープアライブが無効化されている環境)には影響しませんでしたが、修正が上位プロジェクトに統合された場合、他の Hyper ユーザーに影響を及ぼす可能性があります。
Hyper の接続ライフサイクルを追跡した結果、よりターゲットを絞ったアプローチが見つかりました。ディスパッチループの動作を変更するのではなく、シャットダウンが実際に呼び出される地点で修正を適用しました。ソケットをシャットダウンする前に、Hyper はバッファ内の残存データをまずフラッシュすべきです:
pub(crate) fn poll_shutdown(
&mut self,
cx: &mut Context,
) -> Poll> {
ready!(self.poll_flush(cx)?);
Pin::new(&mut self.io).poll_shutdown(cx)
}
これにより、ディスパッチループの変更は不要となります。データ損失が発生する可能性があった瞬間、つまりシャットダウン直前にのみフラッシュを追加します。
私たちに残ったもの
アプリケーションレベルのツールでは、有用な情報を提供するエラー、クラッシュ、またはログエントリは一切検出されませんでした。
原文を表示
The Images service, built in Rust on Workers, runs on every machine in Cloudflare’s edge network. To handle client connections, we use hyper, an open-source HTTP library for Rust.
Last year, we introduced the Images binding to enable custom, programmatic workflows for processing remote images in Workers. At the end of 2025, we rearchitected the binding to provide a more direct, local connection between the Workers runtime and the Images service.
Shortly after rollout, we received reports that transformation requests from the binding were failing — but only intermittently and only for larger images. Even stranger, the responses for these requests returned a 200 status without any errors logged. The image data was simply cut short: A response that should have been two megabytes might arrive with a few hundred kilobytes instead.
We spent six weeks chasing a nearly invisible bug — a race condition that occurred only under specific conditions — in the hyper library that impacted how the Images binding returned processed image data back to the client. In the end, it took four lines of code to fix it.
Hops, handoffs, and hyper
When developers build on Cloudflare, they compose full-stack applications from a set of platform services that are accessible to Workers through bindings. Bindings provide direct APIs to resources on the Developer Platform like compute, storage, AI inference, and media processing.
The Images binding decouples image optimization from delivery; you can transcode, composite, or manipulate images without needing to return the output as an HTTP response. It also lets you apply optimization parameters in any order, rather than following the fixed sequence imposed by the URL interface. Here, a worker can pass image data directly to the Images API, chain operations together, and get the processed result back as a stream:
const result = await env.IMAGES
.input(image)
.transform({ width: 800, rotate: 90 })
.output({ format: "image/avif" });
return result.response();
At a high level, this is how image data moves through our various services:
image
The pipe represents a socket connection between the intermediary and Images, where data is handed off from one process to the next through the kernel’s buffer.
The binding communicates with Images through a socket connection managed by the Workers runtime. A socket connection is a communication channel between two processes. Each end of the socket has buffers that are managed by the operating system’s kernel; these buffers are temporary holding areas where data sits after one side writes it but before the other side reads it.
Hyper manages the connection on the Images service’s side, reading incoming requests from the socket and writing responses back to it.
When a request uses the Images binding, the Images service reads the input, performs the requested optimization operations, and encodes the result. It then passes the entire encoded image to hyper as a single in-memory block.
Hyper writes this response data into its own internal buffer. At this point, hyper considers the encoding work as complete, since it has all the bytes that it needs to send. The next step is to flush its internal buffer to the socket’s outbound buffer, moving the data from the Images service to the intermediary on the other end.
If the reader on the other end is fast, then hyper can flush everything in one pass — the outbound buffer will have room because the reader is consuming data as quickly as it arrives. Once all data is sent, hyper issues a shutdown on the socket, signaling that the connection is finished and no more data will be written. But if the reader is slower (even by a few milliseconds), then the outbound buffer fills up, and hyper needs to wait until there’s room to continue writing.
Taking the local
All incoming traffic on Cloudflare's network passes through FL, an internal intermediary service that runs security and performance features and routes requests to the appropriate backend. When we first launched the binding, image data flowed from the Workers runtime, through FL, to the Images service.
This path was a natural fit for our initial release and follows the same architecture as our URL interface. Over time, though, this coupling with FL became a constraint: Every change to the binding had to follow FL’s release cycle.
In December 2025, the Images team replaced FL with a new intermediary service, an internal worker binding that runs on the same machine. In the original architecture, data moved through FL over network sockets; this path carried the overhead of FL’s full processing pipeline, such as DNS lookups and routing.
The internal binding replaced these with Unix sockets to directly connect the services on the same machine, bypassing FL and the overhead of the network stack. This made the request path to Images faster and gave the team independent control over binding releases.
Within days of the rollout, we received our first customer report.
200 OK (not OK)
The first sign of trouble came from a customer with a non-standard setup: two layers of image processing, where one pipeline was nested inside another.
First, their worker used the Images binding to composite multiple large source images from R2 — a JPEG background plus PNG overlay layers — into a single combined JPEG. Second, they further compressed, transcoded, and resized the result through the URL interface.
image
The bug originated in the inner pipeline’s return path, where the response was truncated before reaching the outer pipeline.
The inner pipeline (transformation binding) handled compositing. The outer pipeline (transformation URL) handled delivery optimizations like scaling and format conversion. This layered approach meant that when the inner pipeline silently returned a truncated response, the only visible error appeared one level up:
error reading a body from connection: end of file before message length reached
The outer pipeline received HTTP 200 from the inner one, with a Content-Length header that promised several megabytes. The actual body was only a fraction of that: In one request, only ~200 KB arrived out of an expected 3.3 MB. The error surfaced in the outer pipeline, but the truncation could have originated in the binding, the intermediary service, the Images service, or somewhere in between.
When a browser receives a truncated image, the result is visible. Depending on the format, the image either renders partially (e.g., with the bottom half missing or gray) or fails to decode entirely, instead displaying a broken image.
Debugging in the dark
From here, we worked inward through the request path, testing each layer to isolate where the truncation was happening. Some of these efforts hit dead ends; others left breadcrumbs that narrowed the search:
Building a reproduction. We built a worker that mimicked the customer’s nested setup, then stripped away layers until we could trigger the bug with the binding alone. A small script let us fire requests in batches. In one early run, 19 out of 25 requests failed. The amount of data that did arrive — roughly 200 KB — was suspiciously close to the size of the socket buffer in production. This confirmed that the problem wasn’t tied to the customer’s configuration and gave us a reliable way to trigger the bug on demand.
Investigating timeouts. Early on, we suspected the truncation might be related to timeout behavior (i.e., the connection was being closed after a time limit). This theory didn’t hold, as the truncation wasn’t correlated with request duration.
Updating hyper version. When the bug was first reported, we were running 0.14.x, while the latest hyper version was around 1.8.x. We tested across hyper versions 0.14, 1.7, and 1.8, just in case the most obvious answer was the correct (and easiest) one. But the bug appeared in each version, which meant that there wasn’t an upstream fix.
Reproducing locally. We ran local integration tests on macOS and a Debian VM. Even under considerable load, our local requests never triggered any failure. Making direct curl requests to the binding socket and replaying captured requests always seemed to work. The bug only appeared on the full production path when there was real concurrency and a real Workers runtime client on the other end of the socket. This led us to suspect the runtime itself.
Ruling out the Workers runtime. We examined the HTTP client that the Workers runtime uses to communicate with Images through the binding socket. None of the traces from either side of the connection showed any syscalls that indicated an unexpected close or early termination. We observed that the client behaved correctly and multiple other services used the same client without issues.
Distributed tracing. By inspecting request traces end-to-end, we confirmed that the truncated body was already present before it reached the outer transformation layer in the customer’s setup. That narrowed the problem to the inner pipeline — the binding path through the Images service.
Instrumenting the intermediary service. We added instrumentation to the intermediary service to measure body sizes before forwarding the response data. The bodies were already truncated by the time they left the Images service, so the intermediary was ruled out.
Deeper tracing within the Images service. At the service level, the request was processed, the image was properly encoded, and the response was sent with HTTP 200.
The only consistent signal was that the bug was timing-dependent: It appeared only on the production path, with real concurrency, and only for larger images.
A kernel of truth
Tools for application-level debugging told only what the system thought it was doing. But according to the system, everything was fine: Tracing said the response was sent; logging reported no errors, and the Images service returned 200 on every request.
To see what the system was actually doing, we attached strace to the Images service. strace records the syscalls that a process makes to the kernel, which could show us exactly which bytes were written, when a shutdown was called, and whether the client sent any termination signal.
Setting up the trace was delicate. strace works by intercepting syscalls as they happen, which adds a small amount of timing overhead to each one. Filtering for a narrow set of syscalls kept that overhead minimal. Broadening the filter, however, slowed the process just enough to shift the timing between the flush and the shutdown check — and make the bug disappear entirely. That alone reinforced our theory that the issue was timing-sensitive.
Using a reproduction worker, we triggered the bug and compared the syscall output between successful and failing requests.
In a successful request, the response is written in chunks as the socket buffer allows, with shutdown called only after all the data is sent. For example, this may look like:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
sendto(42, "\xff\xd8\xff\xe0...", 292352) = 292352
// ... keeps writing until buffer drains ...
sendto(42, "...", 292352) = 292352
shutdown(42, SHUT_WR) = 0
When we reproduced the bug, a failing request looked like:
sendto(42, "HTTP/1.1 200 OK\r\nContent-Length: 14991808\r\n...", ...) = 219264
shutdown(42, SHUT_WR) = 0
Here, there is only one write — just enough for the headers and a sliver of the body — before the shutdown is immediately called. Out of a 14.9 MB response, only about 219 KB was sent. The remaining ~14.8 MB of image data never left hyper’s internal buffer, nor was there any termination signal from the client between the write and the shutdown. Instead, the Images service prematurely shut down the connection on its own, genuinely believing it was finished.
The failing requests confirmed that the bug was a race condition that triggered intermittently. Whether a request succeeded or failed depended on whether the flush and shutdown operations overlapped, which changed from request to request. When the buffer was still full at the exact moment that hyper decided the connection was finished, data was lost.
image
When the reader consumes slower than hyper writes, the outbound buffer fills up. If hyper shuts down the connection before the buffer drains, then only a fraction of the response makes it to the intermediary; this incomplete data gets forwarded back to the Workers runtime and the client.
The December rearchitecture didn't introduce this bug, which had been present in hyper for years across multiple major versions. But the new intermediary changed who was reading on the response side of the socket. Our working theory is that FL, the previous intermediary, consumed data fast enough that the socket buffer rarely filled during a response. The new reader read at a pace that occasionally let the buffer fill during larger responses.
These few milliseconds of backpressure, introduced by an improvement that made everything else faster, were all it took to surface a flaw that had been hiding in plain sight.
Inside the dispatch loop
Hyper's HTTP/1 connection lifecycle is driven by a state machine in a file called dispatch.rs. It runs a loop that reads requests, writes responses, flushes the write buffer to the socket, and decides when to shut down. In simplified form:
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(()));
}
}
}
More precisely, the let _ before poll_flush is where the bug lives.
In Rust, let _ = expr discards the expression's result, including Poll::Pending, the signal that the flush isn’t done yet. The flush might still have megabytes sitting in its buffer, but the loop never finds out.
When a request fails, this is the exact sequence of events:
The Images service finishes encoding the image and hands the entire response to hyper as a single in-memory block.
Hyper writes the block into its internal buffer and marks its write state as Writing::Closed. From an encoding standpoint, the work is done — there is nothing left to encode.
Hyper calls poll_flush to move the buffered data to the socket. In our previous example, the socket accepted about 219 KB. The remaining ~14.8 MB stays in hyper's buffer. The socket is full, so the kernel returns Poll::Pending.
poll_loop discards the Poll::Pending with let _.
It checks wants_read_again(). The full request was already received, so this returns false.
poll_loop returns Poll::Ready(Ok(())), signaling that the loop is finished, even though the flush is not.
poll_shutdown() fires. The SHUT_WR syscall is issued.
The client receives 219 KB and an EOF (end-of-file) indicating that the connection is closed, even though it expects 14.9 MB.
In the second step, hyper marks the write operation as complete as soon as the response body is buffered (i.e., when encoding is finished), rather than when it has actually been flushed. Most of the time, the flush completes in a single pass and this distinction is invisible. On the rare occasions when the socket buffer is full, the flush has to wait — even though hyper doesn't. The bytes are still sitting in hyper’s buffer, waiting to be flushed to the socket. Hyper proceeds to shut down the connection with this data still in the buffer.
This also explains why curl never triggered the bug. Curl reads data as fast as it arrives: The socket buffer never fills, the flush always completes immediately, and the discarded return value is harmless. The production path, with a reader that occasionally paused for a few milliseconds, was the only configuration where the buffer filled at exactly the wrong moment.
Don’t forget to flush
After weeks of investigation, the fix itself was conceptually simple. Hyper needed to check whether the flush was actually done before moving on.
Our reproduction worker confirmed that the bug existed, but it couldn't tell us why a given request failed. Before writing the fix, we needed a test that could trigger the exact socket conditions inside hyper.
We knew the conditions that triggered the bug: a socket that accepts one chunk of data and then blocks. To test with a controlled scenario, we built a custom wrapper around a TCP stream that simulated a full socket buffer. The wrapper accepted 8 KB on the first write, then returned Poll::Pending on every subsequent write, mimicking a reader that stopped draining the buffer.
The test sent a 500 KB response through this constrained socket and checked whether hyper called shutdown while 492 KB was still buffered. Without a fix, it did. With the fix, it waited.
Initially, we applied the fix in hyper’s dispatch loop. Instead of discarding the result of poll_flush, we checked to see whether the flush was actually done:
let flush_result = self.poll_flush(cx)?;
if flush_result.is_pending() {
return Poll::Pending;
}
if !self.conn.wants_read_again() {
return Poll::Ready(Ok(()));
}
If the flush hasn't completed, then the loop returns Poll::Pending to the asynchronous runtime. The runtime waits for the socket to become writable, then wakes the task back up to continue the flush. The connection shuts down only after all data has been sent.
When we deployed this fix, we observed that every byte was written and the shutdown was called only after the buffer was actually empty. The customer who made the first report also confirmed that the issue disappeared.
While our initial solution worked, the dispatch loop wasn’t the right place for the fix. Returning Poll::Pending early could slow down other operations on the same connection by reducing how frequently reads are polled, causing unintended backpressure. It also doesn't correctly handle keepalive connections, where a single connection handles multiple requests in sequence — these should remain reusable even while the previous response is still being flushed. Neither issue affected our particular service (where keepalive is disabled), but both could affect other hyper users if the fix were contributed upstream.
We traced through hyper's connection lifecycle and found a more targeted approach. Rather than changing how the dispatch loop behaves, we applied the fix at the point where shutdown is actually called. Before shutting down the socket, hyper should first flush any remaining data in its buffer:
pub(crate) fn poll_shutdown(
&mut self,
cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
ready!(self.poll_flush(cx)?);
Pin::new(&mut self.io).poll_shutdown(cx)
}
This leaves the dispatch loop unchanged. It adds a flush only at the exact point where data loss would otherwise occur — the moment before shutdown.
What stayed with us
None of the tools at the application level surfaced any errors, crashes, or log entries that provided useful c
関連記事
Talos:自動化された反復的ゲノム再解析による希少疾患診断の拡張
Microsoft Research は、希少疾患の診断を支援するオープンソースツール「Talos」を発表した。このツールは科学的知見の進化に応じて保存されたシーケンシングデータを自動的に再分析し、新たな治療可能証拠を持つ変異を検出する。
2026 年にローカルで実行可能なトップ 7 つのコーディングモデル
KDnuggets が選定した、2026 年版のローカル環境で動作する主要な 7 つのコード生成 AI モデルを紹介している。
Nous Research、Hermes エージェントのスキルシステムに「/learn」機能を追加、手書きなしでワークフローをスラッシュコマンドとして記録可能に
Nous Research はオープンソースの自己改善型エージェント「Hermes Agent」のスキルシステムを拡張し、「/learn」という新コマンドを追加した。これにより、ユーザーはドキュメントやコードなどの資料を指定するだけで、エージェントが自動的に再利用可能なスキル定義ファイル(SKILL.md)を作成できるようになった。
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み