Fix of 'A copy of XXX has been removed from the module tree but is still active!'

概要

Railsのdevelopment環境で開発をしている際に、表題のタイトルのエラーが発生したため調査をしました。

development環境でなぜ発生するのか

development環境のみで発生するのは以下の設定が要因です。

config.cache_classes = false
Python

この設定は、アプリケーション・サーバーを再起動せずにコードの変更を反映させることができるようになります。変更対象のソースコードは予めconfig.autoload_pathsに追加しておいたパスのものが監視されています。

発生する条件は?

発生条件は以下のようなケースです。

class Hoge
  def fuga
    Fuga.new
  end
end
Python
hoge = Hoge.new
ActiveSupport::Dependencies.clear # ソースコード変更を検知してreloadが実行されたと仮定
hoge.fuga # ここで発生
Python

reloadの仕組みは?

rubyのクラスは定数として管理されています。クラス名を参照するということは、rubyにおいては定数を参照しているということになります。
そして、初回起動時に全てのクラスが読み込まれますが、reload時には全ては読み込まれません。reloadが発生すると定数が全てクリアされ、アクセスしたクラスを再度loadし直すという挙動になっています。
ActiveSupport::Dependencies.clearが呼び出されると、All or Nothingで定数がクリアされるので、reload後はアクセスした度に読み込み直していくことになります。

なぜ発生するのか

  1. reloadによってクラスの定数がクリアされたのでhoge.fugaを呼び出した際にFugaクラスがconst_missingを発生
  2. Fugaクラスを探しに行く際にselfの名前解決を行う
  3. 通常はObjectが返却されるが、この場合だとHogeが返される。
  4. reload前後のHogeクラスのobject_idが比較され異なると例外発生

解決方法の考察

上記の通り、問題は2点存在します。

  1. Hogeクラスがreload前後でobject_idが変わっている
  2. Fugaクラスの呼び出しがそもそもconst_missingになっている

この場合、1. のケースを防ぐならunloadableというメソッドをクラスにつけることで回避することができるはず。 …①

また、2.を防ぐためには、以下2通りの方法が考えられます。

  1. クラス名の前にコロンを2つ繋いで::Fugaのようにする…②
  2. ActionDispatch::Reloader.to_prepareなどでreload発生タイミングをフックにFugaクラスをloadし直す…③

解決方法の実施

解決方法を3つ提示しました。ので整理して実際に順に試してみます。

①unloadableでobject_idを変えない

  • 効果あり
  • ただ、発生する可能性のある箇所すべてに埋め込まないといけない & リロードが効かなくなるため断念

②::Fugaのようにトップレベル定義に変更

  • 効果あり
  • ただ、発生する可能性のある箇所すべてに埋め込まないといけないため断念

③reload発生タイミングで再定義

  • 効果あり
  • requireではなく、ActiveSupportの提供するrequire_dependencyによって変更のあった場合も再読込するようにする(参考

注意点

  • const_missingの挙動などはActiveSupport:::ModuleConstMissingによってRails用に拡張されています。