mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
* fix(plugins): drop a plugin's package modules when it unloads A plugin that keeps helpers in a package (providers/feed.py, imported as `from providers.feed import ...`) leaves dotted entries in sys.modules. PluginLoader only tracked bare names: `providers` was namespaced and dropped on unload, `providers.feed` stayed. A reload after a store update imported a fresh `providers`, then got the old `feed` back from the module cache, so the new manager.py ran against the old helpers until the display restarted. A load that failed part-way left them behind the same way. Elections (providers/), flights (enrichment/) and olympics (data/, renderers/) ship packages. The loader now records the dotted modules whose file (or, for a namespace package, every __path__ entry) lies inside the plugin directory. They keep their names while the plugin runs, as before, and unregister_plugin_modules() drops them, only while sys.modules still holds that plugin's module. The failed-load cleanup in load_module() drops them too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): remove a symlinked dev plugin as a link PluginStoreManager._safe_remove_directory, behind uninstall and behind discarding the set-aside copy after an install or update, handed a symlinked dev plugin (scripts/dev/dev_plugin_setup.sh) to shutil.rmtree, which refuses a symlink. The chmod fallback then walked through the link and set every directory and file in the linked checkout to 0700, and the sudo stage refused the resolved path as outside the plugins directory. The removal failed, the link stayed, and the developer's checkout lost its group/other permissions. A dangling link read as already removed, because exists() follows it, and was left behind. A symlink is now unlinked before any other stage runs, and before the exists() check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): load a dev plugin linked in under a different name contained_plugin_dir(), the containment check before a plugin's dependencies are installed, resolved the plugin directory and looked for the resolved folder's name among the plugins directory's entries. A dev plugin symlinked in under its id by a name its checkout does not share -- `dev_plugin_setup.sh link-github foo <url>` clones ledmatrix-foo, the repository naming convention, and links it as plugins/foo -- has no such entry, so install_dependencies() returned False and the load failed with "Dependency installation failed", even with no requirements.txt. When the path sits directly in the plugins directory, the entry it names (the link) is looked up first; anything else is resolved and matched by name as before. The answer is still always rebuilt from a name os.scandir() returned for the plugins directory, so a path outside it is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plugins): release a plugin whose update() raises a BaseException On the async update worker, the wrapped update() finished its bookkeeping (_finish: release the plugin lock, drop the pending slot, state back to ENABLED) only for an Exception. asyncio.CancelledError and SystemExit derive from BaseException, so one raised from update() skipped _finish: the plugin kept its lock and stayed RUNNING for the life of the process, never rescheduled, with every display() skipped as busy. PluginExecutor caught only Exception as well, so its thread died with the call never marked complete and an immediate failure was logged and recorded as a timeout. _target_update now runs _finish for any BaseException and re-raises it, and the executor's thread stores it like any other exception, so it is reported as the operation's failure (PluginError) on both the async and the synchronous path. _finish and _record_update_failure take a BaseException. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): notify config subscribers outside the service lock ConfigService._load_config ran every subscriber while holding _lock. The display's per-plugin subscriber calls PluginManager.apply_config_change, which waits up to PLUGIN_LOCK_TIMEOUT (5 s) for a plugin busy in update(). A save that enables or disables a plugin also flags a reconcile, which the render thread runs: its get_config(), and the unsubscribe() of a plugin it disables, both take _lock, so the panel froze behind every slow callback, up to 5 s per busy plugin. The config is now swapped under _lock and the subscribers are called after it is released, from a copy of the subscriber lists. A separate _notify_lock is held across a whole reload (read, swap, notify), so one reload's notifications still finish before the next one's start. Each callback is checked against the live lists just before it runs, and unsubscribe() waits only for a call of that same callback already in progress (unless it is that callback's own thread), so a callback it removed is not running and will not run once it returns, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
346 lines
13 KiB
Python
346 lines
13 KiB
Python
"""
|
|
Configuration Service
|
|
|
|
Provides centralized configuration management with hot-reload support
|
|
and change notifications.
|
|
|
|
This service wraps ConfigManager and adds:
|
|
- File watching for automatic reload
|
|
- Change notifications to subscribers
|
|
- Thread-safe configuration access
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List, Callable, Tuple
|
|
from collections import defaultdict
|
|
import logging
|
|
import hashlib
|
|
|
|
from src.exceptions import ConfigError
|
|
from src.logging_config import get_logger
|
|
from src.config_manager import ConfigManager
|
|
|
|
|
|
class ConfigService:
|
|
"""
|
|
Centralized configuration service with hot-reload.
|
|
|
|
Features:
|
|
- Automatic file watching and reload
|
|
- Change notifications to subscribers
|
|
- Thread-safe access
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config_manager: Optional[ConfigManager] = None,
|
|
enable_hot_reload: bool = True
|
|
) -> None:
|
|
"""
|
|
Initialize the configuration service.
|
|
|
|
Args:
|
|
config_manager: Optional ConfigManager instance (creates new if None)
|
|
enable_hot_reload: Whether to enable automatic file watching
|
|
"""
|
|
self.logger: logging.Logger = get_logger(__name__)
|
|
self.config_manager: ConfigManager = config_manager or ConfigManager()
|
|
self.enable_hot_reload: bool = enable_hot_reload
|
|
|
|
# Thread safety
|
|
self._lock: threading.RLock = threading.RLock()
|
|
# Held across a whole reload -- read, swap, notify -- so one reload's
|
|
# notifications finish before the next one's start. Subscribers run
|
|
# under this lock and never under _lock: the display's per-plugin
|
|
# subscriber can wait seconds for a busy plugin, and get_config(),
|
|
# subscribe() and unsubscribe() -- called from the render thread --
|
|
# must not wait behind it.
|
|
self._notify_lock: threading.RLock = threading.RLock()
|
|
# (key, callback, thread id) of the callback a notification is running,
|
|
# so unsubscribe() can wait for that one call; signalled on its return.
|
|
self._running_callback: Optional[Tuple[str, Callable[..., None], int]] = None
|
|
self._callback_done = threading.Condition(self._lock)
|
|
|
|
# Current configuration
|
|
self._current_config: Dict[str, Any] = {}
|
|
self._current_checksum: Optional[str] = None
|
|
self._last_modified: Dict[str, float] = {}
|
|
|
|
# Subscribers for change notifications
|
|
# Format: {plugin_id or component_name: [callbacks]}
|
|
self._subscribers: Dict[str, List[Callable[[Dict[str, Any], Dict[str, Any]], None]]] = defaultdict(list)
|
|
|
|
# File watching
|
|
self._watch_thread: Optional[threading.Thread] = None
|
|
self._watch_interval: float = 2.0 # Check every 2 seconds
|
|
self._stop_watching: bool = False
|
|
|
|
# Load initial configuration
|
|
self._load_config()
|
|
|
|
# Start file watching if enabled
|
|
if self.enable_hot_reload:
|
|
self._start_file_watching()
|
|
|
|
def _calculate_checksum(self, config: Dict[str, Any]) -> str:
|
|
"""Calculate checksum of configuration for change detection."""
|
|
config_str = json.dumps(config, sort_keys=True)
|
|
return hashlib.sha256(config_str.encode()).hexdigest()
|
|
|
|
def _load_config(self) -> bool:
|
|
"""
|
|
Load configuration from ConfigManager.
|
|
|
|
Returns:
|
|
True if config changed, False otherwise
|
|
"""
|
|
try:
|
|
with self._notify_lock:
|
|
new_config = self.config_manager.load_config()
|
|
new_checksum = self._calculate_checksum(new_config)
|
|
|
|
with self._lock:
|
|
# Check if config actually changed
|
|
if new_checksum == self._current_checksum:
|
|
self.logger.debug("Configuration unchanged, skipping reload")
|
|
return False
|
|
|
|
# Store old config for change detection
|
|
old_config = self._current_config.copy()
|
|
|
|
# Update current config
|
|
self._current_config = new_config
|
|
self._current_checksum = new_checksum
|
|
|
|
# Notify subscribers, outside _lock (see _notify_lock)
|
|
self._notify_subscribers(old_config, new_config)
|
|
|
|
self.logger.info(
|
|
"Configuration reloaded (checksum: %s)",
|
|
new_checksum[:8]
|
|
)
|
|
|
|
return True
|
|
|
|
except ConfigError as e:
|
|
self.logger.error("Error loading configuration: %s", e, exc_info=True)
|
|
return False
|
|
except Exception as e:
|
|
self.logger.error("Unexpected error loading configuration: %s", e, exc_info=True)
|
|
return False
|
|
|
|
def _notify_subscribers(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
|
"""
|
|
Notify all subscribers of configuration changes.
|
|
|
|
Args:
|
|
old_config: Previous configuration
|
|
new_config: New configuration
|
|
|
|
Called without _lock held. The subscriber lists are copied under it,
|
|
and each callback is checked against them again just before it runs.
|
|
"""
|
|
with self._lock:
|
|
subscribers = {key: list(callbacks) for key, callbacks in self._subscribers.items()}
|
|
|
|
# Notify global subscribers (key: '*')
|
|
for callback in subscribers.get('*', []):
|
|
self._call_subscriber('*', callback, old_config, new_config)
|
|
|
|
# Notify plugin-specific subscribers
|
|
for plugin_id, callbacks in subscribers.items():
|
|
if plugin_id == '*':
|
|
continue
|
|
|
|
old_plugin_config = old_config.get(plugin_id, {})
|
|
new_plugin_config = new_config.get(plugin_id, {})
|
|
|
|
# Only notify if plugin config actually changed
|
|
if old_plugin_config != new_plugin_config:
|
|
for callback in callbacks:
|
|
self._call_subscriber(plugin_id, callback,
|
|
old_plugin_config, new_plugin_config)
|
|
|
|
def _call_subscriber(
|
|
self,
|
|
key: str,
|
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
|
old_config: Dict[str, Any],
|
|
new_config: Dict[str, Any],
|
|
) -> None:
|
|
"""Run one callback, unless it was unsubscribed since the snapshot.
|
|
|
|
unsubscribe() promises that once it returns the callback is neither
|
|
running nor will run: the display unloads the plugin straight after.
|
|
"""
|
|
with self._lock:
|
|
if callback not in self._subscribers.get(key, ()):
|
|
return
|
|
self._running_callback = (key, callback, threading.get_ident())
|
|
try:
|
|
callback(old_config, new_config)
|
|
except Exception as e:
|
|
if key == '*':
|
|
self.logger.error("Error in global config change callback: %s", e, exc_info=True)
|
|
else:
|
|
self.logger.error(
|
|
"Error in config change callback for %s: %s",
|
|
key,
|
|
e,
|
|
exc_info=True
|
|
)
|
|
finally:
|
|
with self._lock:
|
|
self._running_callback = None
|
|
self._callback_done.notify_all()
|
|
|
|
def _check_file_changes(self) -> bool:
|
|
"""
|
|
Check if configuration files have been modified.
|
|
|
|
Returns:
|
|
True if files changed, False otherwise
|
|
"""
|
|
config_path = Path(self.config_manager.get_config_path())
|
|
secrets_path = Path(self.config_manager.get_secrets_path())
|
|
|
|
changed = False
|
|
|
|
# Check main config file
|
|
if config_path.exists():
|
|
mtime = config_path.stat().st_mtime
|
|
if mtime != self._last_modified.get(str(config_path), 0):
|
|
self._last_modified[str(config_path)] = mtime
|
|
changed = True
|
|
|
|
# Check secrets file
|
|
if secrets_path.exists():
|
|
mtime = secrets_path.stat().st_mtime
|
|
if mtime != self._last_modified.get(str(secrets_path), 0):
|
|
self._last_modified[str(secrets_path)] = mtime
|
|
changed = True
|
|
|
|
return changed
|
|
|
|
def _file_watcher_loop(self) -> None:
|
|
"""Main loop for file watching."""
|
|
self.logger.info("Configuration file watcher started")
|
|
|
|
# Initialize last modified times
|
|
config_path = Path(self.config_manager.get_config_path())
|
|
secrets_path = Path(self.config_manager.get_secrets_path())
|
|
|
|
if config_path.exists():
|
|
self._last_modified[str(config_path)] = config_path.stat().st_mtime
|
|
if secrets_path.exists():
|
|
self._last_modified[str(secrets_path)] = secrets_path.stat().st_mtime
|
|
|
|
while not self._stop_watching:
|
|
try:
|
|
if self._check_file_changes():
|
|
self.logger.info("Configuration files changed, reloading...")
|
|
self._load_config()
|
|
|
|
# Sleep with periodic checks for stop signal
|
|
for _ in range(int(self._watch_interval)):
|
|
if self._stop_watching:
|
|
# Set from another thread; mypy keeps the while's narrowing.
|
|
break # type: ignore[unreachable]
|
|
time.sleep(1)
|
|
|
|
except Exception as e:
|
|
self.logger.error("Error in file watcher loop: %s", e, exc_info=True)
|
|
time.sleep(self._watch_interval)
|
|
|
|
self.logger.info("Configuration file watcher stopped")
|
|
|
|
def _start_file_watching(self) -> None:
|
|
"""Start the file watching thread."""
|
|
if self._watch_thread and self._watch_thread.is_alive():
|
|
return
|
|
|
|
self._stop_watching = False
|
|
self._watch_thread = threading.Thread(
|
|
target=self._file_watcher_loop,
|
|
name="ConfigService-Watcher",
|
|
daemon=True
|
|
)
|
|
self._watch_thread.start()
|
|
self.logger.debug("File watching thread started")
|
|
|
|
def _stop_file_watching(self) -> None:
|
|
"""Stop the file watching thread."""
|
|
if self._watch_thread and self._watch_thread.is_alive():
|
|
self._stop_watching = True
|
|
self._watch_thread.join(timeout=5.0)
|
|
if self._watch_thread.is_alive():
|
|
self.logger.warning("File watching thread did not stop gracefully")
|
|
|
|
def get_config(self) -> Dict[str, Any]:
|
|
"""
|
|
Get current configuration (thread-safe).
|
|
|
|
Returns:
|
|
Current configuration dictionary
|
|
"""
|
|
with self._lock:
|
|
return self._current_config.copy()
|
|
|
|
def subscribe(
|
|
self,
|
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
|
plugin_id: Optional[str] = None
|
|
) -> None:
|
|
"""
|
|
Subscribe to configuration changes.
|
|
|
|
Args:
|
|
callback: Function to call when config changes
|
|
Signature: callback(old_config, new_config)
|
|
plugin_id: Optional plugin ID to subscribe to specific plugin changes
|
|
If None, subscribes to all changes
|
|
"""
|
|
key = plugin_id or '*'
|
|
with self._lock:
|
|
if callback not in self._subscribers[key]:
|
|
self._subscribers[key].append(callback)
|
|
self.logger.debug("Subscribed to config changes for %s", key)
|
|
|
|
def unsubscribe(
|
|
self,
|
|
callback: Callable[[Dict[str, Any], Dict[str, Any]], None],
|
|
plugin_id: Optional[str] = None
|
|
) -> None:
|
|
"""
|
|
Unsubscribe from configuration changes.
|
|
|
|
Once this returns the callback is not running and will not be called
|
|
again. A notification that is running this very callback is waited
|
|
for (unless the callback is the caller); one running any other
|
|
callback is not.
|
|
|
|
Args:
|
|
callback: Callback function to remove
|
|
plugin_id: Optional plugin ID (must match subscription)
|
|
"""
|
|
key = plugin_id or '*'
|
|
with self._lock:
|
|
if callback in self._subscribers[key]:
|
|
self._subscribers[key].remove(callback)
|
|
self.logger.debug("Unsubscribed from config changes for %s", key)
|
|
while (self._running_callback is not None
|
|
and self._running_callback[:2] == (key, callback)
|
|
and self._running_callback[2] != threading.get_ident()):
|
|
self._callback_done.wait()
|
|
|
|
def shutdown(self) -> None:
|
|
"""Shutdown the configuration service."""
|
|
self.logger.info("Shutting down configuration service")
|
|
self._stop_file_watching()
|
|
|
|
with self._lock:
|
|
self._subscribers.clear()
|