Ruby Bug #22023 のワークアラウンド:`compile_file` が Prism を使わない問題への対処
Ruby の +PRISM ビルドで RubyVM::InstructionSequence.compile_file が Prism パーサーではなく parse.y を使ってしまうバグにより、有効な Alternative Pattern 構文が SyntaxError になる問題が発生していました。Bootsnap はこのバグを検出して自動的にワークアラウンドを適用するパッチを追加しました。
背景
Bootsnap 1.24.0 へのアップグレード後、Alternative Pattern を含むコードが Rails コンソールでのみ SyntaxError になるという問題が報告されました(#539)。
問題を再現するコードは以下のように単純なものです:
def foo(bars)
case bars
in [one, "a" | "b" => two]
puts "#{one} - #{two}"
end
end
Bootsnap なしの通常の Ruby/IRB では正常に動作するにもかかわらず、Bootsnap を経由したコンソールでは alternative pattern after variable capture (SyntaxError) というエラーが発生していました。根本原因は Ruby Bug #22023 であり、+PRISM フラグ付きでビルドされた Ruby において RubyVM::InstructionSequence.compile_file が Prism パーサーではなく古い parse.y を使用してしまうことにあります。Prism は Alternative Pattern の変数キャプチャを正しく解析できますが、parse.y ではそれができません。
Bootsnap は compile_file を直接呼び出してバイトコードを生成・キャッシュするため、このパーサーの誤選択を直撃します。通常の ruby コマンドはこの影響を受けないため、Bootsnap 利用時のみ問題が表面化していました。
技術的な変更
ワークアラウンドは「バグ検出」「compile_file のパッチ」「compile_file_prism のパッチ」という3段階の構造で実装されています。
第1段階:バグの検出
まず、バグを確認するためのカナリアファイル ruby_bug_22023_canary.rb が追加されました。このファイルには問題の Alternative Pattern 構文が含まれており、compile_file がこのファイルのコンパイルに成功するかどうかでバグの有無を判定します。
# rubocop:disable Style/FrozenStringLiteralComment
def foo(bars)
case bars
in [one, "a" | "b" => two]
puts "#{one} - #{two}"
end
end
_ = "test"
# rubocop:enable Style/FrozenStringLiteralComment
iseq.rb の初期化時にこのカナリアファイルをコンパイルし、SyntaxError が発生した場合に has_ruby_bug_22023 = true と判定します:
has_ruby_bug_22023 = if defined?(RubyVM::InstructionSequence) && RubyVM::InstructionSequence.respond_to?(:compile_file_prism)
begin
RubyVM::InstructionSequence.compile_file(File.expand_path("../ruby_bug_22023_canary.rb", __FILE__))
false
rescue SyntaxError
true
end
end
compile_file_prism メソッドの存在確認を条件に加えることで、Prism を持たない旧来の Ruby では検出処理自体をスキップします。
第2段階:compile_file のパッチ
バグが検出され、かつ RUBY_DESCRIPTION に +PRISM が含まれる場合に限り、PatchRubyBug22023 モジュールを singleton_class に prepend して compile_file を上書きします:
if has_ruby_bug_22023 && RUBY_DESCRIPTION.include?("+PRISM")
module PatchRubyBug22023
def compile_file(path, options = nil)
compile_file_prism(path, options)
end
# ...
end
RubyVM::InstructionSequence.singleton_class.prepend(PatchRubyBug22023)
end
論理的なワークアラウンドは compile_file の代わりに compile_file_prism を呼ぶことです。しかし compile_file_prism 自体にも別のバグがあり、コンパイルオプション(frozen_string_literal など)を無視してしまいます。
第3段階:compile_file_prism のパッチ
compile_file_prism がコンパイルオプションを正しく扱えているかを、カナリアファイルを使って検証します。frozen_string_literal: true オプションを渡してコンパイルした結果の文字列が frozen? でなければ、オプションが無視されていると判断します:
has_ruby_bug_22023_bis = !RubyVM::InstructionSequence.compile_file_prism(
File.expand_path("../ruby_bug_22023_canary.rb", __FILE__),
{frozen_string_literal: true},
).eval.frozen?
if has_ruby_bug_22023_bis
def compile_file_prism(path, options = nil)
compile_prism(::File.read(path), path, path, nil, options)
end
end
この二次バグが検出された場合は、compile_file_prism も compile_prism(ファイル内容を文字列として読み込んでからコンパイルするメソッド)で置き換えます。
SyntaxError のハンドリング追加
既存の input_to_storage にも変更が加えられ、SyntaxError を UNCOMPILABLE として扱うようになりました:
# 変更前
def input_to_storage(_, path)
iseq = RubyVM::InstructionSequence.compile_file(path, @compile_options)
begin
iseq.to_binary
rescue TypeError
UNCOMPILABLE # ruby bug #18250
end
end
# 変更後
def input_to_storage(_, path)
iseq = RubyVM::InstructionSequence.compile_file(path, @compile_options)
iseq.to_binary
rescue TypeError, SyntaxError # Ruby [Bug #18250] & [Bug #22023]
UNCOMPILABLE
end
これにより、パッチが適用されない環境でも SyntaxError でクラッシュせず、キャッシュをスキップしてフォールバックできるようになりました。
設計判断
バグ検出をランタイムのカナリア方式にした点が重要な設計判断です。Rubyバージョンの文字列マッチングではなく、実際に問題のある構文をコンパイルして例外をキャッチする方式を採用しています。これにより、将来この Ruby バグが修正されたバージョンでは自動的にパッチが適用されなくなり、不要なモンキーパッチが残り続けるリスクを避けられます。
二段階のバグ検出(has_ruby_bug_22023 と has_ruby_bug_22023_bis)も同様の考え方に基づいています。compile_file_prism のオプション無視バグは別の Ruby バグであり、独立して修正される可能性があります。それぞれのバグを個別に検出・パッチすることで、片方のみが修正された場合にも余分なパッチが当たらない設計になっています。
singleton_class.prepend を使ったパッチ適用は、メソッドの完全な置き換えではなく super チェーンを保持する方式です。また、モジュール定義内でカナリアを使ったバグ検出を行うことで、パッチの適用判断がモジュールのロード時に完結するよう整理されています。
まとめ
この変更は、Ruby 自体のバグに起因する問題に対して、ランタイム検出とモンキーパッチで透過的に対処するという Bootsnap らしいアプローチを示しています。バグの有無を実行時に動的に確認する設計により、将来の Ruby バグ修正時に自動的にワークアラウンドが無効化される自己完結した実装になっています。