[rails/bootsnap] `require`装飾中に`Bootsnap.unload_cache!`が呼ばれた場合の安全性を向上
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_indexがnilになり、その後のidentifyやregisterメソッド呼び出しで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の状態を制御する必要があるケースで重要な改善となります。