`IO.copy_stream` が `Buffer#write` の戻り値不正とデータ破損を引き起こすバグを修正
Buffer#write が整数ではなく配列を返していたため IO.copy_stream が TypeError を発生させ、さらに文字列参照の共有によるサイレントなデータ破損も引き起こしていた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#write(actionpack/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#write(actionpack/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? によるガード付き複製という小さな工夫が、サイレントなデータ破損という発見困難なバグの根本的な解決策になっている点が本変更の技術的な肝です。