Ruby Bug #22023 のワークアラウンド:`compile_file` が Prism を使わない問題への対処

rails/bootsnap

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_classprepend して 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_prismcompile_prism(ファイル内容を文字列として読み込んでからコンパイルするメソッド)で置き換えます。

SyntaxError のハンドリング追加

既存の input_to_storage にも変更が加えられ、SyntaxErrorUNCOMPILABLE として扱うようになりました:

# 変更前
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_22023has_ruby_bug_22023_bis)も同様の考え方に基づいています。compile_file_prism のオプション無視バグは別の Ruby バグであり、独立して修正される可能性があります。それぞれのバグを個別に検出・パッチすることで、片方のみが修正された場合にも余分なパッチが当たらない設計になっています。

singleton_class.prepend を使ったパッチ適用は、メソッドの完全な置き換えではなく super チェーンを保持する方式です。また、モジュール定義内でカナリアを使ったバグ検出を行うことで、パッチの適用判断がモジュールのロード時に完結するよう整理されています。

まとめ

この変更は、Ruby 自体のバグに起因する問題に対して、ランタイム検出とモンキーパッチで透過的に対処するという Bootsnap らしいアプローチを示しています。バグの有無を実行時に動的に確認する設計により、将来の Ruby バグ修正時に自動的にワークアラウンドが無効化される自己完結した実装になっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
b93983e6

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の構成が明確です。各セクションの役割も適切に果たされています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きのシンタックスハイライト(```ruby:path/to/file.rb)や、PR・Issueへのリンク記法([#123](URL))がガイドライン通りに正しく使用されています。

対象読者への適合性 ✓ PASS

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

「Prism」「parse.y」「モンキーパッチ」「singleton_class.prepend」などの専門用語を前提としており、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

各セクション、各パラグラフが総論→各論の構造になっており、段落の冒頭にトピックセンテンスが配置されているため、非常に読みやすいです。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

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

記事で引用されているコードは、提供されたDiffの内容と正確に一致しています。特に、バグ検出ロジックやパッチ適用のコードが忠実に再現されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR情報と一致した、正確な技術用語が使用されています。Prism, parse.y, Alternative Patternなどの用語の使い分けも適切です。

説明の技術的正確性 ✓ PASS

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

PRのDescriptionやDiffで示されたコードの意図を正確に説明しています。`compile_file`のバグ、`compile_file_prism`の二次バグ、そしてそれらに対する二段階のワークアラウンドという複雑なロジックを、技術的に正確に解説できています。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionやDiff内のコードで裏付けられています。根拠のない推測や憶測は一切含まれていません。

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

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

PR番号(#540)、Issue番号(#539)、Ruby Bug番号(#22023)など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRのタイトル「Workaround Ruby [Bug #22023]」の内容をより具体的に説明しており、PRの主旨と完全に一致しています。

外部知識の正確性 ✓ PASS

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

PRで直接言及されていない外部知識(LTS情報、リリース日など)の追加はなく、提供された情報源の範囲内で記事が構成されています。

時間表現の正確性 ✓ PASS

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

「発生していました」「追加しました」といった過去形の表現が適切に使用されており、時間的な歪曲はありません。