`IO.copy_stream` が `Buffer#write` の戻り値不正とデータ破損を引き起こすバグを修正

rails/rails

Buffer#write が整数ではなく配列を返していたため IO.copy_streamTypeError を発生させ、さらに文字列参照の共有によるサイレントなデータ破損も引き起こしていた2つのバグが修正されました。

背景

IO.copy_stream(src, response.stream) を呼び出すと TypeError: no implicit conversion of Array into Integer が発生するという問題が #57186 で報告されました。IO.copy_stream のドキュメントには、書き込み先の write メソッドは書き込んだバイト数を Integer で返さなければならないと明記されています。しかし、ActionDispatch::Response::Buffer#write および ActionController::Live::Buffer#write の両実装はこの契約を満たしていませんでした。

問題はさらにもう1つ潜んでいました。IO.copy_stream は内部で文字列バッファを再利用する仕様を持ちます。Buffer#write が渡された文字列を複製せずに参照のままエンキューしていたため、書き込み後にそのバッファが IO.copy_stream によって書き換えられると、既にキューに積まれたチャンクの内容が無警告で破壊されるサイレントなデータ破損が発生していました。

この2つのバグは互いに独立しており、どちらも IO.copy_stream との組み合わせで顕在化する問題です。

技術的な変更

2つのバグはそれぞれ最小限の変更で修正されました。ActionDispatch::Response::Buffer#write では戻り値と文字列複製の両方が、ActionController::Live::Buffer#write では戻り値のみが修正対象です。

ActionDispatch::Response::Buffer#writeactionpack/lib/action_dispatch/http/response.rb)では、@buf.push の引数に string.dup を渡すよう変更し、string.bytesize を返すようにしました。

変更前:

def write(string)
  @str_body = nil
  @response.commit!
  @buf.push string
end

変更後:

def write(string)
  @str_body = nil
  @response.commit!
  @buf.push string.frozen? ? string : string.dup
  string.bytesize
end

string.frozen? のチェックを挿入しているのは、イミュータブルな文字列(フローズンリテラルなど)は変更される心配がないため dup を省略できる、という最適化です。ミュータブルな文字列のみを複製することで、不要なアロケーションを回避しています。

ActionController::Live::Buffer#writeactionpack/lib/action_controller/metal/live.rb)では、既存のロジック末尾に string.bytesize を返す行が追加されました。

変更後(追加部分):

# 既存の write 処理の末尾
string.bytesize

テストは両クラスに対して追加されており、#write が正しいバイト数を返すこと、および IO.copy_stream を経由したデータが破損しないことを検証しています。

設計判断

string.frozen? によるガード付き dup の採用は、安全性と効率性を両立させた判断です。フローズン文字列はRuby内で広く使われており(リテラル文字列、frozen_string_literal: true 環境など)、これらを無条件に複製すると不要なオブジェクト生成が増加します。frozen? で分岐することで、変異リスクのある文字列だけを複製する最小コストの解決策になっています。

ActionController::Live::Buffer#write については、PRの記述によると戻り値の型修正(Integer を返す)のみが変更として加えられており、文字列複製の変更は含まれていません。一方で、ActionDispatch::Response::Buffer#write には戻り値の修正に加えて文字列複製の変更も適用されており、両バッファで対応範囲が異なります。

これらの変更は IO 互換インターフェースの契約write は Integer を返す)への準拠を明示しており、IO.copy_stream 以外の IO 互換コンシューマーに対しても同様の堅牢性をもたらします。

まとめ

本PRは Array#push の戻り値がそのまま Buffer#write の戻り値として伝播していた設計の欠落を修正し、IO プロトコルへの準拠を両バッファ実装で実現しました。string.frozen? によるガード付き複製という小さな工夫が、サイレントなデータ破損という発見困難なバグの根本的な解決策になっている点が本変更の技術的な肝です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
263633de

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
3回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

記事の構成が「総論→各論→結論」というガイドラインに完全に準拠しています。リード文、背景、技術的な変更、設計判断、まとめが明確に分離されており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```言語:ファイルパス)およびGitHubのPR・Issueへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

専門知識を持つエンジニアを対象としており、専門用語の解説も適切です。過度な初心者向けの説明はなく、技術的なレベルが読者層に適合しています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論→結論の構成になっており、かつ各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られています。これにより、記事の要点を素早く把握できます。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、提供されたDiff情報と完全に一致しています。変更前後のコード、ファイルパスともに正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「サイレントなデータ破損」「IO互換インターフェースの契約」「フローズンリテラル」など、技術用語が正確かつ文脈に即して適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

`Buffer#write`が配列を返していた問題と、文字列参照の共有によるデータ破損問題の根本原因についての説明が、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのDescriptionやDiff内のコードによって裏付けられています。特に、2つのBufferクラスで修正範囲が異なる点を正確に指摘しており、PR内容を深く理解していることが伺えます。ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#57190)およびIssue番号(#57186)が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題(TypeErrorの修正とデータ破損の修正)を正確に要約しており、PRの内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に含まれないバージョン情報やサポート状況などの外部知識の追記はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に関する歪曲はなく、過去に発生した問題とその修正という事実関係が正確に記述されています。