DiffDaily

Deep & Concise - OSS変更の定点観測

[rails/bootsnap] `require`装飾中に`Bootsnap.unload_cache!`が呼ばれた場合の安全性を向上

rails/bootsnap

Context

BootsnapはRubyのrequireをオーバーライドして、ロードパスのキャッシュを提供することでRailsアプリケーションの起動を高速化するライブラリです。しかし、requireされたファイル内でBootsnap.unload_cache!が呼ばれると、キャッシュインデックスがnilになり、その後のキャッシュ操作でエラーが発生する問題がありました。

この問題は、Railsをロードする前に多数のファイルをrequireする特定のエントリーポイントで実際に発生しており、実運用環境での影響が確認されていました。

Technical Detail

問題の発生箇所

Bootsnapのrequire装飾では、ファイルをrequireした後にキャッシュインデックスへの登録処理を行います。しかし、requireされたファイル内でBootsnap.unload_cache!が呼ばれると、Bootsnap::LoadPathCache.loaded_features_indexnilになり、その後のidentifyregisterメソッド呼び出しでNoMethodErrorが発生していました。

修正内容

lib/bootsnap/load_path_cache/core_ext/kernel_require.rbで、キャッシュインデックスへのアクセスに安全なナビゲーション演算子(&.)を使用するよう変更されました。

変更後(キャッシュミス時のパス):

if (cursor = Bootsnap::LoadPathCache.loaded_features_index.cursor(string_path))
  ret = require_without_bootsnap(path)

  # The file we required may have unloaded the cache
  resolved = Bootsnap::LoadPathCache.loaded_features_index&.identify(string_path, cursor)
  Bootsnap::LoadPathCache.loaded_features_index&.register(string_path, resolved)

  return ret
else
  return require_without_bootsnap(path)
end

変更後(キャッシュヒット時のパス):

else
  # Note that require registers to $LOADED_FEATURES while load does not.
  ret = require_without_bootsnap(resolved)

  # The file we required may have unloaded the cache
  Bootsnap::LoadPathCache.loaded_features_index&.register(string_path, resolved)
  return ret
end

テストの追加

2つのテストケースが追加され、キャッシュヒット時とキャッシュミス時の両方でBootsnap.unload_cache!が安全に処理されることが検証されています。

キャッシュミス時のテスト:

def test_unload_cache_from_require_on_cache_miss
  skip("Need a working Process.fork to test in isolation") unless Process.respond_to?(:fork)
  begin
    assert_nil LoadPathCache.load_path_cache
    cache = Tempfile.new("cache")
    pid = Process.fork do
      LoadPathCache.setup(cache_path: cache.path, development_mode: true, ignore_directories: nil)
      dir = File.realpath(Dir.mktmpdir)
      LoadPathCache.loaded_features_index.expects(:key?).returns(false)
      LoadPathCache.load_path_cache.expects(:find).returns(LoadPathCache::FALLBACK_SCAN)
      LoadPathCache.loaded_features_index.expects(:cursor).returns(12)

      $LOAD_PATH.push(dir)
      path = "#{dir}/a.rb"
      File.write(path, <<~RUBY)
        Bootsnap.unload_cache!
      RUBY

      require(path)
    end
    _, status = Process.wait2(pid)
    assert_predicate status, :success?
  ensure
    cache.close
    cache.unlink
  end
end

テストではProcess.forkを使用して隔離された環境でキャッシュのアンロードを検証しています。mochaを使ってキャッシュミスをシミュレートし、requireされたファイル内でBootsnap.unload_cache!が呼ばれてもプロセスが正常終了することを確認しています。

Impact

この修正により、requireされたファイル内でBootsnap.unload_cache!が呼ばれても、安全にキャッシュ操作をスキップできるようになりました。これは、特殊なエントリーポイントを持つアプリケーションや、動的にBootsnapの状態を制御する必要があるケースで重要な改善となります。