fix(plugins): prevent module collision between plugins with shared module names (#265)

When plugins share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py), the first plugin to load would populate
sys.modules with its version, and subsequent plugins would reuse it
instead of loading their own. This caused hockey-scoreboard to use
soccer-scoreboard's ScrollDisplay class, which passes unsupported kwargs
to ScrollHelper.__init__(), breaking Vegas scroll mode entirely.

Fix: evict stale bare-name module entries from sys.modules before each
plugin's exec_module, and delete bare entries after namespace isolation
so they can't leak to the next plugin.

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-23 17:22:55 -05:00
committed by GitHub
parent b92ff3dfbd
commit 976c10c4ac

View File

@@ -228,6 +228,43 @@ class PluginLoader:
continue continue
return result return result
def _evict_stale_bare_modules(self, plugin_dir: Path) -> dict:
"""Temporarily remove bare-name sys.modules entries from other plugins.
Before exec_module, scan the current plugin directory for .py files.
For each, if sys.modules has a bare-name entry whose ``__file__`` lives
in a *different* directory, remove it so Python's import system will
load the current plugin's version instead of reusing the stale cache.
Returns:
Dict mapping evicted module names to their module objects
(for restoration on error).
"""
resolved_dir = plugin_dir.resolve()
evicted: dict = {}
for py_file in plugin_dir.glob("*.py"):
mod_name = py_file.stem
if mod_name.startswith("_"):
continue
existing = sys.modules.get(mod_name)
if existing is None:
continue
existing_file = getattr(existing, "__file__", None)
if not existing_file:
continue
try:
if not Path(existing_file).resolve().is_relative_to(resolved_dir):
evicted[mod_name] = sys.modules.pop(mod_name)
self.logger.debug(
"Evicted stale module '%s' (from %s) before loading plugin in %s",
mod_name, existing_file, plugin_dir,
)
except (ValueError, TypeError):
continue
return evicted
def _namespace_plugin_modules( def _namespace_plugin_modules(
self, plugin_id: str, plugin_dir: Path, before_keys: set self, plugin_id: str, plugin_dir: Path, before_keys: set
) -> None: ) -> None:
@@ -254,12 +291,13 @@ class PluginLoader:
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys): for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
namespaced = f"_plg_{safe_id}_{mod_name}" namespaced = f"_plg_{safe_id}_{mod_name}"
sys.modules[namespaced] = mod sys.modules[namespaced] = mod
# Keep sys.modules[mod_name] as an alias to the same object. # Remove the bare sys.modules entry. The module object stays
# Removing it would cause lazy intra-plugin imports (e.g. a # alive via the namespaced key and all existing Python-level
# deferred ``import scroll_display`` inside a method) to # bindings (``from scroll_display import X`` already bound X
# re-import from disk and create a second, inconsistent copy # to the class object). Leaving bare entries would cause the
# of the module. The next plugin's exec_module will naturally # NEXT plugin's exec_module to find the cached entry and reuse
# overwrite the bare entry with its own version. # it instead of loading its own version.
sys.modules.pop(mod_name, None)
namespaced_names.add(namespaced) namespaced_names.add(namespaced)
self.logger.debug( self.logger.debug(
"Namespace-isolated module '%s' -> '%s' for plugin %s", "Namespace-isolated module '%s' -> '%s' for plugin %s",
@@ -345,6 +383,11 @@ class PluginLoader:
# _namespace_plugin_modules and error cleanup only target # _namespace_plugin_modules and error cleanup only target
# sub-modules, not the main module entry itself. # sub-modules, not the main module entry itself.
before_keys = set(sys.modules.keys()) before_keys = set(sys.modules.keys())
# Evict stale bare-name modules from other plugin directories
# so Python's import system loads fresh copies from this plugin.
evicted = self._evict_stale_bare_modules(plugin_dir)
try: try:
spec.loader.exec_module(module) spec.loader.exec_module(module)
@@ -352,6 +395,10 @@ class PluginLoader:
# cannot collide with identically-named modules from other plugins # cannot collide with identically-named modules from other plugins
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys) self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
except Exception: except Exception:
# Restore evicted modules so other plugins are unaffected
for evicted_name, evicted_mod in evicted.items():
if evicted_name not in sys.modules:
sys.modules[evicted_name] = evicted_mod
# Clean up the partially-initialized main module and any # Clean up the partially-initialized main module and any
# bare-name sub-modules that were added during exec_module # bare-name sub-modules that were added during exec_module
# so they don't leak into subsequent plugin loads. # so they don't leak into subsequent plugin loads.