フォークプロセスでのビューリローダーフック漏れを修正する `ReloadersCollection`
ViewReloader#deactivate と新設の ReloadersCollection により、Spring などのフォークプロセスが app.reloaders.clear を呼んだ際に、ファイルシステムリゾルバのフックも確実に除去されるようになりました。これにより、不要な Dir.[] + File.mtime スキャンがフォーク後のワーカープロセスで繰り返し発生する問題が解消されます。
背景
ViewReloader は railtie 初期化時に PathRegistry.file_system_resolver_hooks へ rebuild_watcher フックを登録しますが、フォークプロセスがリローダーを破棄する際にこのフックが残留するという問題がありました。Spring のテストワーカーは app.reloaders.clear で配列からリローダーを削除しますが、フックは file_system_resolver_hooks に残り続けます。その結果、フォーク後のワーカーで prepend_view_path が呼ばれるたびに、全ビューディレクトリに対してファイルスキャンが実行されていました。
本PRは #57269 のフォローアップです。#57269 は早期ブート時の空ウォッチャー構築を防いだ変更であり、本PRはさらに踏み込んで、フォークワーカーでファイル監視が不要な場合にフック自体を完全に除去する仕組みを導入しています。
この問題の影響はフォークプロセスに限らず、prepend_view_path が呼ばれるたびにスキャンが走るため、エンジンの on_load コールバックが多い大規模アプリケーションほど顕著に現れます。#57269 の計測では、Shopify のモノレポにて Spring ウォームな状態での prepend_view_path × 10 回が10回の実リビルドから0回に削減された事例が示されています。
技術的な変更
変更は大きく2つの軸で構成されています。ViewReloader への deactivate メソッドの追加と、app.reloaders のコレクション型の刷新です。
ViewReloader への create ファクトリと deactivate の導入
actionview/lib/action_view/cache_expiry.rb に ViewReloader.create クラスメソッドと deactivate インスタンスメソッドが追加されました。
変更前:
view_reloader = ActionView::CacheExpiry::ViewReloader.new(watcher: app.config.file_watcher)
ActionView::PathRegistry.file_system_resolver_hooks << view_reloader.method(:rebuild_watcher)
変更後:
view_reloader = ActionView::CacheExpiry::ViewReloader.create(watcher: app.config.file_watcher)
create の内部では、フック登録をインスタンス生成と一体化させています。また、initialize でフックを @hook = method(:rebuild_watcher) として保持することで、後から同一の Method オブジェクトを参照できるようにしています。
def self.create(watcher:, &block)
reloader = new(watcher: watcher, &block)
ActionView::PathRegistry.file_system_resolver_hooks << reloader.hook
reloader
end
def deactivate
ActionView::PathRegistry.file_system_resolver_hooks.delete(@hook)
@watcher = nil
@watched_dirs = nil
end
attr_reader :hook
deactivate は @watcher と @watched_dirs もリセットするため、仮にインスタンスが参照され続けても再スキャンは発生しません。Array#delete の同一性判定は Method オブジェクトの == を使うため、method(:rebuild_watcher) を @hook に固定して保持する設計が重要です。
ReloadersCollection の新設
railties/lib/rails/application/reloaders_collection.rb に新しいクラスが追加され、app.reloaders の型が従来の Array から ReloadersCollection に変更されました。
class ReloadersCollection
include Enumerable
def clear
@reloaders.each { |r| r.deactivate if r.respond_to?(:deactivate) }
@reloaders.clear
end
def delete(reloader)
reloader.deactivate if reloader.respond_to?(:deactivate)
@reloaders.delete(reloader)
end
end
ReloadersCollection は Enumerable をインクルードし、<<・size・empty?・each を実装することで、既存の Array としての利用箇所に対して互換性を維持しています。clear および delete の際に respond_to?(:deactivate) でガードしているため、deactivate を持たない既存のリローダーもそのまま混在できます。
# 変更前
@reloaders = []
# 変更後
@reloaders = ReloadersCollection.new
app.reloaders を直接操作している既存コードは、Enumerable のインターフェースを通じている限り変更不要です。
設計判断
フック除去の責任をコレクション側に持たせた ことで、呼び出し側は clear / delete を従来通り呼ぶだけでクリーンアップが自動的に実行されます。Spring の app.reloaders.clear 呼び出しはそのままで、追加の変更なしに恩恵を受けられます。
deactivate を respond_to? で検査するオプトイン設計が採用されています。これにより、deactivate を実装していない既存のリローダー(例: ルートリローダーなど)を ReloadersCollection に混在させても安全に動作します。インターフェースを強制するのではなく、能力の有無を実行時に判定するアプローチは、後方互換性と拡張性のバランスを取る判断です。
また、フック登録を initialize から切り離し create ファクトリメソッド に移した点も注目です。initialize でフックを登録すると、テストコードが new を使った際に意図せずグローバル状態を汚染します。テストでは引き続き new を使って副作用なしにインスタンスを生成でき、create を呼んだ場合のみフック登録が行われます。
まとめ
本PRは、ViewReloader のライフサイクルとグローバルフックの整合性という、見落とされがちな問題に対処しています。ReloadersCollection という薄いラッパーを導入することで、既存の app.reloaders.clear という呼び出しパターンを変えることなく、リローダーの破棄とフックのクリーンアップを原子的に結びつける設計を実現しています。