fix(plugins): namespace-isolate modules for safe parallel loading (#237)

* fix(plugins): prevent KeyError race condition in module cleanup

When multiple plugins have modules with the same name (e.g.,
background_data_service.py), the _clear_conflicting_modules function
could raise a KeyError if a module was removed between iteration
and deletion. This race condition caused plugin loading failures
with errors like: "Unexpected error loading plugin: 'background_data_service'"

Changes:
- Use sys.modules.pop(mod_name, None) instead of del sys.modules[mod_name]
  to safely handle already-removed modules
- Apply same fix to plugin unload in plugin_manager.py for consistency
- Fix typo in sports.py: rankself._team_rankings_cacheings ->
  self._team_rankings_cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(plugins): namespace-isolate plugin modules to prevent parallel loading collisions

Multiple sport plugins share identically-named Python files (scroll_display.py,
game_renderer.py, sports.py, etc.). When loaded in parallel via ThreadPoolExecutor,
bare module names collide in sys.modules causing KeyError crashes.

Replace _clear_conflicting_modules with _namespace_plugin_modules: after exec_module
loads a plugin, its bare-name sub-modules are moved to namespaced keys
(e.g. _plg_basketball_scoreboard_scroll_display) so they cannot collide.
A threading lock serializes the exec_module window where bare names temporarily exist.

Also updates unload_plugin to clean up namespaced sub-modules from sys.modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): address review feedback on namespace isolation

- Fix main module accidentally renamed: move before_keys snapshot to
  after sys.modules[module_name] insertion so the main entry is excluded
  from namespace renaming and error cleanup
- Use Path.is_relative_to() instead of substring matching for plugin
  directory containment checks to avoid false-matches on overlapping
  directory names
- Add try/except around exec_module to clean up partially-initialized
  modules on failure, preventing leaked bare-name entries
- Add public unregister_plugin_modules() method on PluginLoader so
  PluginManager doesn't reach into private attributes during unload
- Update stale comment referencing removed _clear_conflicting_modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): remove unused plugin_dir_str variable

Leftover from the old substring containment check, now replaced by
Path.is_relative_to().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): extract shared helper for bare-module filtering

Hoist plugin_dir.resolve() out of loops and deduplicate the bare-module
filtering logic between _namespace_plugin_modules and the error cleanup
block into _iter_plugin_bare_modules().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(plugins): keep bare-name alias to prevent lazy import duplication

Stop removing bare module names from sys.modules after namespacing.
Removing them caused lazy intra-plugin imports (deferred imports inside
methods) to re-import from disk, creating a second inconsistent module
copy. Keeping both the bare and namespaced entries pointing to the same
object avoids this. The next plugin's exec_module naturally overwrites
the bare entry with its own version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-02-10 22:47:24 -05:00
committed by GitHub
parent 4a9fc2df3a
commit b99be88cec
3 changed files with 143 additions and 65 deletions

View File

@@ -890,7 +890,7 @@ class SportsUpcoming(SportsCore):
away_text = '' away_text = ''
elif self.show_ranking: elif self.show_ranking:
# Show ranking only if available # Show ranking only if available
away_rank = rankself._team_rankings_cacheings.get(away_abbr, 0) away_rank = self._team_rankings_cache.get(away_abbr, 0)
if away_rank > 0: if away_rank > 0:
away_text = f"#{away_rank}" away_text = f"#{away_rank}"
else: else:

View File

@@ -10,6 +10,7 @@ import importlib
import importlib.util import importlib.util
import sys import sys
import subprocess import subprocess
import threading
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, Tuple, Type from typing import Dict, Any, Optional, Tuple, Type
import logging import logging
@@ -35,6 +36,14 @@ class PluginLoader:
self.logger = logger or get_logger(__name__) self.logger = logger or get_logger(__name__)
self._loaded_modules: Dict[str, Any] = {} self._loaded_modules: Dict[str, Any] = {}
self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names self._plugin_module_registry: Dict[str, set] = {} # Maps plugin_id to set of module names
# Lock to serialize module loading when plugins share module names
# (e.g., scroll_display.py, game_renderer.py across sport plugins).
# During exec_module, bare-name sub-modules temporarily appear in
# sys.modules; the lock prevents concurrent plugins from seeing each
# other's entries. After exec_module, _namespace_plugin_modules
# moves those bare names to namespaced keys (e.g.
# _plg_basketball_scoreboard_scroll_display) so they never collide.
self._module_load_lock = threading.Lock()
def find_plugin_directory( def find_plugin_directory(
self, self,
@@ -190,49 +199,91 @@ class PluginLoader:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True) self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False return False
def _clear_conflicting_modules(self, plugin_dir: Path, plugin_id: str) -> None: @staticmethod
def _iter_plugin_bare_modules(
plugin_dir: Path, before_keys: set
) -> list:
"""Return bare-name modules from plugin_dir added after before_keys.
Returns a list of (mod_name, module) tuples for modules that:
- Were added to sys.modules after before_keys snapshot
- Have bare names (no dots)
- Have a ``__file__`` inside plugin_dir
""" """
Clear cached modules from sys.modules that could conflict with the plugin being loaded. resolved_dir = plugin_dir.resolve()
result = []
When multiple plugins have modules with the same name (e.g., data_fetcher.py), for key in set(sys.modules.keys()) - before_keys:
Python's module cache can cause the wrong module to be imported. This method if "." in key:
removes cached modules from other plugin directories to ensure the correct continue
module is loaded. mod = sys.modules.get(key)
Args:
plugin_dir: The directory of the plugin being loaded
plugin_id: The plugin identifier
"""
plugin_dir_str = str(plugin_dir)
# Find modules to remove: those from other plugin directories
# that could conflict with modules in the current plugin
modules_to_remove = []
for mod_name, mod in list(sys.modules.items()):
if mod is None: if mod is None:
continue continue
mod_file = getattr(mod, "__file__", None)
# Skip standard library and site-packages modules if not mod_file:
mod_file = getattr(mod, '__file__', None)
if mod_file is None:
continue continue
try:
if Path(mod_file).resolve().is_relative_to(resolved_dir):
result.append((key, mod))
except (ValueError, TypeError):
continue
return result
# Check if this module is from a different plugin directory def _namespace_plugin_modules(
# We look for modules that: self, plugin_id: str, plugin_dir: Path, before_keys: set
# 1. Have simple names (no dots) - these are the ones that conflict ) -> None:
# 2. Are loaded from a plugin-repos directory but not the current plugin """
if '.' not in mod_name and 'plugin-repos' in mod_file: Move bare-name plugin modules to namespaced keys in sys.modules.
if plugin_dir_str not in mod_file:
modules_to_remove.append(mod_name)
self.logger.debug(
"Clearing cached module '%s' from %s to avoid conflict with plugin %s",
mod_name, mod_file, plugin_id
)
# Remove conflicting modules After exec_module loads a plugin's entry point, Python will have added
for mod_name in modules_to_remove: the plugin's local modules (scroll_display, game_renderer, …) to
del sys.modules[mod_name] sys.modules under their bare names. This method renames them to
``_plg_<plugin_id>_<module>`` so they cannot collide with identically-
named modules from other plugins.
The plugin code keeps working because ``from scroll_display import X``
binds ``X`` to the class *object*, not to the sys.modules entry.
Args:
plugin_id: Plugin identifier
plugin_dir: Plugin directory path
before_keys: Snapshot of sys.modules keys taken *before* exec_module
"""
safe_id = plugin_id.replace("-", "_")
namespaced_names: set = set()
for mod_name, mod in self._iter_plugin_bare_modules(plugin_dir, before_keys):
namespaced = f"_plg_{safe_id}_{mod_name}"
sys.modules[namespaced] = mod
# Keep sys.modules[mod_name] as an alias to the same object.
# Removing it would cause lazy intra-plugin imports (e.g. a
# deferred ``import scroll_display`` inside a method) to
# re-import from disk and create a second, inconsistent copy
# of the module. The next plugin's exec_module will naturally
# overwrite the bare entry with its own version.
namespaced_names.add(namespaced)
self.logger.debug(
"Namespace-isolated module '%s' -> '%s' for plugin %s",
mod_name, namespaced, plugin_id,
)
# Track for cleanup during unload
self._plugin_module_registry[plugin_id] = namespaced_names
if namespaced_names:
self.logger.info(
"Namespace-isolated %d module(s) for plugin %s",
len(namespaced_names), plugin_id,
)
def unregister_plugin_modules(self, plugin_id: str) -> None:
"""Remove namespaced sub-modules and cached module for a plugin from sys.modules.
Called by PluginManager during unload to clean up all module entries
that were created when the plugin was loaded.
"""
for ns_name in self._plugin_module_registry.pop(plugin_id, set()):
sys.modules.pop(ns_name, None)
self._loaded_modules.pop(plugin_id, None)
def load_module( def load_module(
self, self,
@@ -243,6 +294,15 @@ class PluginLoader:
""" """
Load a plugin module from file. Load a plugin module from file.
Module loading is serialized via _module_load_lock because plugins are
loaded in parallel (ThreadPoolExecutor) and multiple sport plugins
share identically-named local modules (scroll_display.py,
game_renderer.py, sports.py, etc.).
After loading, bare-name modules from the plugin directory are moved
to namespaced keys in sys.modules (e.g. ``_plg_basketball_scoreboard_scroll_display``)
so they cannot collide with other plugins.
Args: Args:
plugin_id: Plugin identifier plugin_id: Plugin identifier
plugin_dir: Plugin directory path plugin_dir: Plugin directory path
@@ -257,35 +317,51 @@ class PluginLoader:
self.logger.error(error_msg) self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
# Clear any conflicting modules from other plugins before loading with self._module_load_lock:
self._clear_conflicting_modules(plugin_dir, plugin_id) # Add plugin directory to sys.path if not already there
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
# Add plugin directory to sys.path if not already there # Import the plugin module
plugin_dir_str = str(plugin_dir) module_name = f"plugin_{plugin_id.replace('-', '_')}"
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str)
# Import the plugin module # Check if already loaded
module_name = f"plugin_{plugin_id.replace('-', '_')}" if module_name in sys.modules:
self.logger.debug("Module %s already loaded, reusing", module_name)
return sys.modules[module_name]
# Check if already loaded spec = importlib.util.spec_from_file_location(module_name, entry_file)
if module_name in sys.modules: if spec is None or spec.loader is None:
self.logger.debug("Module %s already loaded, reusing", module_name) error_msg = f"Could not create module spec for {entry_file}"
return sys.modules[module_name] self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
spec = importlib.util.spec_from_file_location(module_name, entry_file) module = importlib.util.module_from_spec(spec)
if spec is None or spec.loader is None: sys.modules[module_name] = module
error_msg = f"Could not create module spec for {entry_file}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
module = importlib.util.module_from_spec(spec) # Snapshot AFTER inserting the main module so that
sys.modules[module_name] = module # _namespace_plugin_modules and error cleanup only target
spec.loader.exec_module(module) # sub-modules, not the main module entry itself.
before_keys = set(sys.modules.keys())
try:
spec.loader.exec_module(module)
self._loaded_modules[plugin_id] = module # Move bare-name plugin modules to namespaced keys so they
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id) # cannot collide with identically-named modules from other plugins
self._namespace_plugin_modules(plugin_id, plugin_dir, before_keys)
except Exception:
# Clean up the partially-initialized main module and any
# bare-name sub-modules that were added during exec_module
# so they don't leak into subsequent plugin loads.
sys.modules.pop(module_name, None)
for key, _ in self._iter_plugin_bare_modules(plugin_dir, before_keys):
sys.modules.pop(key, None)
raise
self._loaded_modules[plugin_id] = module
self.logger.debug("Loaded module %s for plugin %s", module_name, plugin_id)
return module return module

View File

@@ -415,10 +415,12 @@ class PluginManager:
if plugin_id in self.plugin_last_update: if plugin_id in self.plugin_last_update:
del self.plugin_last_update[plugin_id] del self.plugin_last_update[plugin_id]
# Remove module from sys.modules if present # Remove main module from sys.modules if present
module_name = f"plugin_{plugin_id.replace('-', '_')}" module_name = f"plugin_{plugin_id.replace('-', '_')}"
if module_name in sys.modules: sys.modules.pop(module_name, None)
del sys.modules[module_name]
# Delegate sub-module and cached-module cleanup to the loader
self.plugin_loader.unregister_plugin_modules(plugin_id)
# Remove from plugin_modules # Remove from plugin_modules
self.plugin_modules.pop(plugin_id, None) self.plugin_modules.pop(plugin_id, None)