カバレッジ計測中でも `enable_frozen_string_literal` が動作するように
Ruby 4.0.4以降を対象に、Coverage モジュールが有効な状態でも Bootsnap.enable_frozen_string_literal が正しく機能するよう修正されました。旧バージョンのRubyでは警告を出しながらフォールバックする形で既存動作との互換性が維持されます。
背景
これまで enable_frozen_string_literal とコードカバレッジ計測は共存できない問題がありました。Coverage モジュールが実行中の場合、ISeq(InstructionSequence)のダンプ・ロードが妨げられるため、Bootsnapはカバレッジ検出時にキャッシュ処理をスキップする設計になっていました。load_iseq メソッド内の return nil if coverage_on? がその証拠で、frozen string literalコンパイラを選択していても無条件にバイパスされていました。
その結果、test_boot_frozen_string_literal_and_coverage というテストは skip("Need to find a workaround for this...") のままになっており、カバレッジ計測環境での動作は未解決の課題として残っていました。Ruby 4.0.4でISeqのダンプ・ロード周りの制約が解消されたことで、この問題に対処できるようになりました。
技術的な変更
カバレッジ検出ロジックが load_iseq から fetch クラスメソッドに移動され、コンパイラの種別に応じた細かい制御が可能になりました。
変更前のコードでは、load_iseq の冒頭でカバレッジを確認し、有効であれば即座に nil を返していました。これはfrozen string literalコンパイラが選択されている場合でも例外なく適用されていました。
変更前:
module InstructionSequenceMixin
def load_iseq(path)
# Having coverage enabled prevents iseq dumping/loading.
return nil if coverage_on?
...
end
end
変更後は fetch メソッド内でカバレッジを検出し、コンパイラの種別ごとに3通りの分岐を設けています。
変更後:
COVERAGE_SUPPORTED = RUBY_VERSION >= "4.0.4"
@coverage_support_warning_emitted = false
def self.fetch(path, cache_dir: ISeq.cache_dir)
compiler = compiler_selector&.call(path) || default_compiler
if coverage_on?
return nil if compiler.equal?(DEFAULT)
if COVERAGE_SUPPORTED
return compiler.input_to_output(File.read(path.to_s), path.to_s, nil)
elsif !@coverage_support_warning_emitted
@coverage_support_warning_emitted = true
warn(<<~MSG)
Using `Bootsnap.enable_frozen_string_literal` with code coverage enabled is only supported on Ruby 4.0.4+.
Files loaded while coverage is on, will have mutable string literals.
MSG
end
return nil
end
...
end
カバレッジが有効な場合の挙動を整理すると次のとおりです:
-
DEFAULT コンパイラ選択時: キャッシュをスキップ(
nilを返す)。従来と同じ動作。 -
frozen string literal コンパイラ選択時 + Ruby 4.0.4以降: キャッシュを経由せず
input_to_outputを直接呼び出してコンパイル結果を返す。 -
frozen string literal コンパイラ選択時 + Ruby 4.0.4未満: 一度だけ警告を出力し、
nilを返してフォールバック。
また、coverage_on? メソッドの実装もRubyバージョンで分岐するよう整理されました。Ruby 3.1未満では Coverage.running? を、3.1以降では Coverage.state != :idle を使用します。これは Coverage.state がRuby 3.1で追加された新しいAPIであるためです。
if RUBY_VERSION < "3.1."
def self.coverage_on?
defined?(Coverage) && Coverage.running?
end
else
def self.coverage_on?
defined?(Coverage) && Coverage.state != :idle
end
end
テスト面では、test_boot_frozen_string_literal_and_coverage がスキップされていた状態から、test_coverage_working_with_frozen_string_literal として実際に動作を検証するテストに置き換えられました。新たに追加された coverage_accurate.rb と coverage_test.rb フィクスチャは、カバレッジデータが正確に計測されているかを検証するとともに、string literalのfrozen状態も CHECK_STRING_LITERALS 環境変数を通じて確認します。
設計判断
キャッシュをバイパスして直接コンパイルする という方式が、カバレッジ有効時の解決策として採用されています。
カバレッジが有効な状態でISeqをダンプ・ロードできない制約はRuby 4.0.4未満では解消できないため、frozen string literalを適用したいコンパイラについては input_to_output を直接呼び出すことでキャッシュを迂回します。これはパフォーマンス上の最適化(キャッシュ)を犠牲にしつつも、機能の正確性(frozen string literal の適用)を優先する判断です。一方、DEFAULT コンパイラにとってはキャッシュが主目的であるため、カバレッジ有効時はそのまま nil を返すことでRuby本来の処理に委ねています。
警告の重複出力を防ぐ @coverage_support_warning_emitted フラグを設けることで、旧Rubyでの劣化動作を開発者に一度だけ通知する設計も注目に値します。ノイズを最小限に抑えながら問題を可視化する、実用的なアプローチです。
まとめ
この変更は、カバレッジ計測とfrozen string literalの最適化が長らく両立できなかった制約を、Rubyバージョンに応じた細かい分岐によって解消したものです。Ruby 4.0.4以降では両機能を同時に享受できるようになり、旧バージョンでも明示的な警告によって挙動の変化が把握できるようになっています。