`RubyVM::ISeq.compile_option` 変更時のカスタムコンパイラオプション同期を修正
BootsnapのISeqキャッシュにおいて、RubyVM::InstructionSequence.compile_option が実行時に変更された際、カスタムコンパイラのオプションが追従しない問題を修正しました。
背景
RubyVM::InstructionSequence.compile_option はRubyのバイトコードコンパイル設定をグローバルに変更できるAPIで、tailcall_optimization や frozen_string_literal などのオプションを制御します。Bootsnapはコンパイル済みISeqをキャッシュする際にこの設定値をキャッシュキーの一部として利用しており、設定が変わればキャッシュが無効化される仕組みになっています。
しかし、Bootsnapが独自の カスタムコンパイラ(Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL など)を使用している場合、コンパイラ初期化時に固定されたオプションを保持するため、後から compile_option がミューテート(変更)されても、カスタムコンパイラ側のオプションが更新されないまま古いキャッシュが使われ続ける可能性がありました。
技術的な変更
Compiler クラスへの update_options メソッド追加と、enable_frozen_string_literal の実装変更という2つの修正が行われています。
lib/bootsnap/compile_cache/iseq.rb の Compiler クラスに update_options メソッドが追加されました。このメソッドは、コンストラクタで渡されたオプション(@options)と現在の RubyVM::InstructionSequence.compile_option の関係を評価し、@compile_options を動的に更新します。
変更前:
class Compiler
attr_reader :namespace, :compile_options
def initialize(namespace = nil, compile_options = nil)
@namespace = namespace
@compile_options = compile_options
end
end
変更後:
class Compiler
attr_reader :namespace
def initialize(namespace = nil, compile_options = nil)
@namespace = namespace
@options = compile_options.freeze
update_options
end
def update_options
@compile_options = if @options.nil? || @options < RubyVM::InstructionSequence.compile_option
nil
else
RubyVM::InstructionSequence.compile_option.merge(@options).freeze
end
end
end
update_options の内部では、@options < RubyVM::InstructionSequence.compile_option という比較が鍵になります。これはHashの部分集合チェックで、カスタムオプションが現在のグローバル設定に包含されている(つまり上書き不要)ならば nil(通常のコンパイルパス)を使い、そうでなければグローバル設定にカスタムオプションをマージして使用するという判断です。
lib/bootsnap.rb の enable_frozen_string_literal も合わせて修正されました。以前は compiler_selector を nil に設定し FROZEN_STRING_LITERAL コンパイラを直接セットするアプローチを取っていましたが、変更後は RubyVM::InstructionSequence.compile_option 自体に frozen_string_literal: true をマージして設定する方式に変わっています。
変更前:
Bootsnap::CompileCache::ISeq.compiler_selector = nil
Bootsnap::CompileCache::ISeq.default_compiler = Bootsnap::CompileCache::ISeq::FROZEN_STRING_LITERAL
変更後:
options = RubyVM::InstructionSequence.compile_option.merge(frozen_string_literal: true)
RubyVM::InstructionSequence.compile_option = options
テストも追加されており、test_key_compile_option_custom_compiler では FROZEN_STRING_LITERAL コンパイラ使用中に compile_option を変更した際、キャッシュキーが正しく変化することを検証しています。
設計判断
カスタムオプションをコンパイラ内部に保持する代わりに、グローバルな compile_option を信頼の源泉(source of truth)として扱う設計に統一されました。
enable_frozen_string_literal の変更がその考え方を明確に示しています。以前はBootsnapの内部コンパイラオブジェクトを差し替えることで frozen_string_literal を有効化していましたが、この方法はグローバルオプションとコンパイラオプションが別々に管理される状態を生み出していました。新しい実装では RubyVM::InstructionSequence.compile_option に直接書き込むことで、Bootstrapのキャッシュキー更新機構(compile_option_updated)が自然に機能するようになります。
update_options メソッドを Compiler インスタンスに持たせることで、compile_option が変更されるたびに各コンパイラが自身のオプションを再計算できる拡張点を設けています。Hashの部分集合演算子(<)を利用した判定は、「グローバル設定が既にカスタムオプションを包含しているなら追加オプション不要」というロジックを簡潔に表現しており、冗長なマージ操作を避ける実用的な選択です。
まとめ
この変更は、Bootsnapのカスタムコンパイラとグローバルな compile_option の同期問題を、内部状態の二重管理を解消することで根本的に修正しています。frozen_string_literal などのオプションを compile_option 経由で統一管理する設計への移行により、実行時に compile_option がミューテートされるケースでもキャッシュの整合性が保たれるようになりました。