fix(plugin-system): unload/update race, failed-load cleanup, limits validation, schema lookup, install rollback (#653)

* fix(plugin-system): unload/update race, failed-load module cleanup, limits validation, schema lookup, install rollback, op-queue dedupe

- unload_plugin takes the per-plugin lock (5s bounded) before cleanup(),
  and an update() that finishes after its plugin was unloaded no longer
  sets the state back to ENABLED.
- A load that fails after import drops plugin_<id> and its submodules
  and forgets its manager fonts, so a fixed plugin reloads new code.
- Resource limits are validated as non-negative numbers: 400 at
  POST /plugins/limits, bad cached records ignored with one warning.
  Route docstrings note health/metrics reset and limits only change the
  web process's view.
- SchemaManager.get_schema_path resolves each search dir via
  resolve_plugin_dir (manifest id, ledmatrix-<id>) before the literal
  paths; plugins/ still before plugin-repos/. Misses cached 30s and
  logged once at DEBUG.
- install_from_url sets an existing copy aside and restores it if the
  move fails, under the per-plugin reinstall lock.
- Operation queue refuses a second pending op for a plugin and trims
  _operations with history.
- get_vegas_render_width reads display_manager.width first.
- get_logger in store/schema/health/resource/saved_repositories;
  UTF-8 reads in store_manager and state_manager.
- Docs: update_interval precedence (manifest over config) stated where
  users are told to set it in config.

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

* fix(web): build the limits 400 message from the field name, not an exception

CodeQL flagged str(e) flowing into the response. invalid_limit_field()
returns the offending field without raising, and limits_from_dict uses it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 10:41:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0e9e2cabba
commit c00bf5e8e6
24 changed files with 869 additions and 49 deletions
+14 -7
View File
@@ -776,28 +776,35 @@ class BasePlugin(ABC):
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
a plugin that already sizes itself from ``display_manager.width`` needs
no changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
display width: ``display_manager.width``, which falls back to the
canvas size when ``matrix`` is None (hardware init failed).
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
# display_manager.width first, as CLAUDE.md asks of every plugin: it
# already reads matrix.width when there is a matrix. matrix.width is
# only the fallback for a display_manager without a width (a test
# double, an older wrapper).
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
if width:
return int(width)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
return 128
def get_vegas_content(self) -> Optional[Any]:
"""
+2
View File
@@ -97,6 +97,8 @@ def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError:
# Reachable: str.isdigit() accepts characters int() rejects, such as
# a superscript "\u00b2" -- "1.\u00b2.0" lands here.
return None
while len(nums) < 3:
nums.append(0)
+18
View File
@@ -85,6 +85,17 @@ class PluginOperationQueue:
f"Plugin {plugin_id} already has an active operation: "
f"{active_op.operation_id} ({active_op.operation_type.value})"
)
# _active_operations only holds the *running* one, so a second
# request while the first still waits in the queue (a double-
# clicked Install) used to be queued too, and both ran back to
# back. Refuse it the same way.
for queued_op in self._operations.values():
if queued_op.plugin_id == plugin_id and queued_op.status == OperationStatus.PENDING:
raise ValueError(
f"Plugin {plugin_id} already has an active operation: "
f"{queued_op.operation_id} ({queued_op.operation_type.value})"
)
# Create operation
operation = PluginOperation(
@@ -288,7 +299,14 @@ class PluginOperationQueue:
if len(self._operation_history) > self.max_history:
# Remove oldest operations
self._operation_history.sort(key=lambda op: op.created_at)
dropped = self._operation_history[:-self.max_history]
self._operation_history = self._operation_history[-self.max_history:]
# ...and forget them in the status map too, which otherwise kept
# every operation ever enqueued for the life of the process. A
# still-pending or running one is never dropped from lookups.
for op in dropped:
if op.status not in (OperationStatus.PENDING, OperationStatus.RUNNING):
self._operations.pop(op.operation_id, None)
def shutdown(self) -> None:
"""Shutdown the operation queue and worker thread."""
+3 -2
View File
@@ -6,10 +6,11 @@ and circuit breaker state. Provides automatic recovery mechanisms.
"""
import time
import logging
from typing import Dict, Optional, Any, Tuple
from enum import Enum
from src.logging_config import get_logger
class CircuitState(Enum):
"""Circuit breaker states."""
@@ -43,7 +44,7 @@ class PluginHealthTracker:
self.failure_threshold = failure_threshold
self.cooldown_period = cooldown_period
self.half_open_timeout = half_open_timeout
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
# In-memory health state (also persisted to cache)
self._health_state: Dict[str, Dict[str, Any]] = {}
+67
View File
@@ -52,6 +52,10 @@ class PluginManager:
- PluginExecutor: Handles plugin execution with timeout and error isolation
- PluginStateManager: Manages plugin state machine
"""
# How long unload_plugin() waits for an in-flight update() to finish
# before tearing the instance down anyway.
UNLOAD_LOCK_TIMEOUT = 5.0
def __init__(self, plugins_dir: str = "plugins",
config_manager: Optional[Any] = None,
@@ -408,10 +412,12 @@ class PluginManager:
try:
if not plugin_instance.validate_config():
self.logger.error("Plugin %s configuration validation failed", plugin_id)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR)
return False
except Exception as e:
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
@@ -456,12 +462,34 @@ class PluginManager:
except PluginError as e:
self.logger.error("Plugin error loading %s: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
except Exception as e:
self.logger.error("Unexpected error loading plugin %s: %s", plugin_id, e, exc_info=True)
self._discard_failed_load(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False
def _discard_failed_load(self, plugin_id: str) -> None:
"""Forget a plugin's imported module and font registrations after a
failed load.
load_module() reuses ``plugin_<id>`` from sys.modules, so a module
left behind by a load that failed after import (instantiation,
validate_config, on_enable) would keep serving the old code even
after the user fixes the plugin and reloads it. Never raises.
"""
try:
sys.modules.pop(f"plugin_{plugin_id.replace('-', '_')}", None)
self.plugin_loader.unregister_plugin_modules(plugin_id)
except Exception as e: # pragma: no cover - defensive
self.logger.debug("Could not drop modules of %s: %s", plugin_id, e)
try:
if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'):
self.font_manager.forget_manager_fonts(plugin_id)
except Exception as e:
self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e)
#: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with
@@ -607,6 +635,28 @@ class PluginManager:
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
# Take the plugin's lock so cleanup()/on_disable() can't run while
# the update worker is mid-update() on this instance. Bounded: an
# update() that hangs past PluginExecutor's timeout keeps holding the
# lock from its lingering thread, and unload must still go through.
lock = self.get_plugin_lock(plugin_id)
lock_acquired = lock.acquire(timeout=self.UNLOAD_LOCK_TIMEOUT)
if not lock_acquired:
self.logger.warning(
"Plugin %s still busy after %.1fs; unloading without its lock",
plugin_id, self.UNLOAD_LOCK_TIMEOUT)
try:
return self._unload_plugin_locked(plugin_id)
finally:
if lock_acquired:
lock.release()
def _unload_plugin_locked(self, plugin_id: str) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock."""
if plugin_id not in self.plugins: # unloaded while we waited
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
try:
plugin = self.plugins[plugin_id]
@@ -905,6 +955,14 @@ class PluginManager:
updating, since a scheduler that propagates a plugin bug stops every
other plugin too.
Precedence, first match wins: the ``get_update_interval()`` hook, then
``update_interval`` in the plugin's **manifest**, then
``update_interval`` in the plugin's section of config.json, then 60s.
So a config value only drives the scheduler for a plugin whose
manifest sets none; when the manifest sets one, the config value is
ignored here (a plugin may still read it itself, e.g. to skip fetches
inside update()).
The static result is cached per plugin_id after the first lookup, so
the manifest/config resolution is not repeated on every scheduling
tick of the display loop. A change to ``update_interval`` in
@@ -1206,6 +1264,15 @@ class PluginManager:
return
finished['done'] = True
try:
# The plugin was unloaded (or reloaded as a new instance)
# while this update() ran: unload_plugin() already cleared its
# lifecycle state, so recording success/failure here would
# resurrect a torn-down plugin as ENABLED. Only release.
if self.plugins.get(plugin_id) is not plugin_instance:
if lock is not None:
with self._pending_lock:
self._pending_updates.discard(plugin_id)
return
# Drop the queue reservation *before* the state goes back to
# ENABLED. The other order leaves a window where a scheduler
# sees ENABLED, reserves the plugin, then finds it still in
+62 -3
View File
@@ -5,12 +5,14 @@ Tracks resource usage (memory, CPU, execution time) for plugins.
Provides resource limits and performance monitoring.
"""
import math
import time
import logging
import threading
from typing import Dict, Optional, Any, Callable
from dataclasses import dataclass, field, fields
from src.logging_config import get_logger
try:
import psutil
PSUTIL_AVAILABLE = True
@@ -31,6 +33,52 @@ class ResourceLimits:
warning_threshold: float = 0.8 # Warning at 80% of limit
_LIMIT_FIELDS = ('max_memory_mb', 'max_cpu_percent', 'max_execution_time',
'warning_threshold')
def invalid_limit_field(data: Any) -> Optional[str]:
"""The first field of a limits mapping that isn't a valid limit, or None.
``"limits"`` when ``data`` isn't a mapping at all. Separate from
limits_from_dict so a caller can report the problem without passing an
exception's text back to a client.
"""
if not isinstance(data, dict):
return 'limits'
for name in _LIMIT_FIELDS:
value = data.get(name)
if value is None:
continue
# bool is an int subclass; True is not a limit anyone meant.
if (isinstance(value, bool) or not isinstance(value, (int, float))
or not math.isfinite(value) or value < 0):
return name
return None
def limits_from_dict(data: Any) -> ResourceLimits:
"""Build ResourceLimits from a JSON-shaped mapping, validating each value.
A dataclass does not enforce its annotations, so ResourceLimits built from
raw request JSON or a cached record happily stores ``"50"`` -- and then
every monitored update() raises TypeError comparing a float with it. Each
``max_*`` value must be absent/None (no limit) or a non-negative number;
``warning_threshold`` defaults to 0.8. Unknown keys are ignored.
Raises:
ValueError: naming the first offending field.
"""
bad = invalid_limit_field(data)
if bad == 'limits':
raise ValueError(f"limits must be an object, got {type(data).__name__}")
if bad:
raise ValueError(
f"{bad} must be a non-negative number or null, got {data.get(bad)!r}")
return ResourceLimits(**{name: data[name] for name in _LIMIT_FIELDS
if data.get(name) is not None})
@dataclass
class ResourceMetrics:
"""Resource usage metrics for a plugin.
@@ -86,11 +134,12 @@ class PluginResourceMonitor:
"""
self.cache_manager = cache_manager
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
# Resource metrics per plugin
self._metrics: Dict[str, ResourceMetrics] = {}
self._limits: Dict[str, ResourceLimits] = {}
self._bad_limits_warned: set = set()
# When each plugin's metrics last reached the cache. Metrics change on
# every call, so they cannot be de-duplicated the way health state can;
# they are rate-limited instead. See _METRICS_PERSIST_INTERVAL.
@@ -230,7 +279,17 @@ class PluginResourceMonitor:
cache_key = self._get_limits_key(plugin_id)
cached = self.cache_manager.get(cache_key, max_age=None)
if cached:
self._limits[plugin_id] = ResourceLimits(**cached)
try:
self._limits[plugin_id] = limits_from_dict(cached)
except ValueError as e:
# Treat as no limits rather than letting every update
# of this plugin raise; warn once, not on every call.
if plugin_id not in self._bad_limits_warned:
self._bad_limits_warned.add(plugin_id)
self.logger.warning(
"Ignoring cached resource limits for %s: %s",
plugin_id, e)
return None
else:
return None
return self._limits[plugin_id]
+2 -2
View File
@@ -5,11 +5,11 @@ Manages saved GitHub repository URLs for easy plugin discovery and installation.
"""
import json
import logging
import os
from pathlib import Path
from typing import List, Dict, Optional
from src.logging_config import get_logger
from src.plugin_system.repo_urls import normalize_repo_url
@@ -24,7 +24,7 @@ class SavedRepositoriesManager:
config_path: Path to JSON file storing saved repositories
"""
self.config_path = Path(config_path)
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
self.repositories = self._load_repositories()
def _load_repositories(self) -> List[Dict[str, str]]:
+62 -8
View File
@@ -8,6 +8,7 @@ Provides utilities for extracting defaults, validating configurations, and manag
import copy
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import jsonschema
@@ -15,6 +16,8 @@ from jsonschema import Draft7Validator, ValidationError
from src.core_config_keys import CORE_CONFIG_KEYS
from src.element_style import expand_style_elements
from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir
def _renders_as_object(prop: Dict[str, Any]) -> bool:
@@ -367,7 +370,7 @@ class SchemaManager:
device-wide ``location`` that seeds plugin location defaults.
Omitting it simply leaves schema defaults untouched.
"""
self.logger = logger or logging.getLogger(__name__)
self.logger = logger or get_logger(__name__)
self.plugins_dir = plugins_dir
self.project_root = project_root or Path.cwd()
self.config_manager = config_manager
@@ -377,23 +380,67 @@ class SchemaManager:
# Default config cache: plugin_id -> default config dict
self._defaults_cache: Dict[str, Dict[str, Any]] = {}
# Schema-path misses: plugin_id -> monotonic time of the miss. A
# lookup now scans each search directory's manifests, and plugins
# without a schema are asked about on every page render.
self._schema_path_misses: Dict[str, float] = {}
self._schema_miss_logged: set = set()
#: How long a "no schema" answer is reused before the directories are
#: searched again -- short, so a plugin installed by a path that doesn't
#: call invalidate_cache() (a dev symlink, a manual copy) still shows up.
SCHEMA_MISS_TTL = 30.0
def get_schema_path(self, plugin_id: str) -> Optional[Path]:
"""
Get the path to a plugin's config_schema.json file.
Tries multiple locations in order:
Each search directory -- plugins_dir, then PROJECT_ROOT/plugins, then
PROJECT_ROOT/plugin-repos -- is first resolved the way the plugin
loader resolves it (``plugin_dirs.resolve_plugin_dir``: the directory
whose manifest declares the id, else ``<id>`` / ``ledmatrix-<id>``,
case-insensitively). Only if none of those holds a schema are the
literal locations tried:
1. plugins_dir / plugin_id / config_schema.json
2. PROJECT_ROOT / plugins / plugin_id / config_schema.json
3. PROJECT_ROOT / plugin-repos / plugin_id / config_schema.json
4. a case-insensitive match of plugin_id in plugins/ and plugin-repos/
A miss is remembered for SCHEMA_MISS_TTL seconds (or until
invalidate_cache()) and logged once, at DEBUG: PluginManager already
warns at load time about a plugin that ships no schema.
Args:
plugin_id: Plugin identifier
Returns:
Path to schema file or None if not found
"""
missed_at = self._schema_path_misses.get(plugin_id)
if missed_at is not None and time.monotonic() - missed_at < self.SCHEMA_MISS_TTL:
return None
search_dirs = []
if self.plugins_dir:
search_dirs.append(Path(self.plugins_dir))
search_dirs.extend([self.project_root / 'plugins',
self.project_root / 'plugin-repos'])
# Resolved the way the loader does, so a plugin installed as
# ``ledmatrix-<id>`` or under a directory named differently from its
# manifest id still gets its schema. One directory at a time keeps
# the documented plugins/-before-plugin-repos/ order.
possible_paths = []
for search_dir in search_dirs:
try:
resolved = resolve_plugin_dir(
plugin_id, [search_dir], prefix=True, case_insensitive=True)
except Exception as e: # pragma: no cover - defensive
self.logger.debug(f"Could not resolve {plugin_id} in {search_dir}: {e}")
resolved = None
if resolved is not None:
possible_paths.append(resolved / 'config_schema.json')
# Try plugins_dir if set
if self.plugins_dir:
@@ -416,9 +463,14 @@ class SchemaManager:
for path in possible_paths:
if path.exists():
self.logger.debug(f"Found schema for {plugin_id} at {path}")
self._schema_path_misses.pop(plugin_id, None)
self._schema_miss_logged.discard(plugin_id)
return path
self.logger.warning(f"Schema file not found for plugin {plugin_id}")
self._schema_path_misses[plugin_id] = time.monotonic()
if plugin_id not in self._schema_miss_logged:
self._schema_miss_logged.add(plugin_id)
self.logger.debug(f"Schema file not found for plugin {plugin_id}")
return None
def load_schema(self, plugin_id: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
@@ -481,10 +533,12 @@ class SchemaManager:
if plugin_id:
self._schema_cache.pop(plugin_id, None)
self._defaults_cache.pop(plugin_id, None)
self._schema_path_misses.pop(plugin_id, None)
self.logger.debug(f"Invalidated cache for plugin {plugin_id}")
else:
self._schema_cache.clear()
self._defaults_cache.clear()
self._schema_path_misses.clear()
self.logger.debug("Invalidated all schema caches")
def extract_defaults_from_schema(self, schema: Dict[str, Any], prefix: str = '') -> Dict[str, Any]:
+1 -1
View File
@@ -320,7 +320,7 @@ class PluginStateManager:
return
try:
with open(self.state_file, 'r') as f:
with open(self.state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
with self._lock:
+32 -15
View File
@@ -21,10 +21,10 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any, Tuple, Set
import logging
from jsonschema import Draft7Validator, ValidationError
from src.logging_config import get_logger
from src.common.permission_utils import (
ensure_directory_permissions, get_plugin_dir_mode, install_requirements_file,
sudo_remove_directory,
@@ -79,7 +79,7 @@ class PluginStoreManager:
``config/uninstalled_plugins.json`` under the project root.
"""
self.plugins_dir = Path(plugins_dir)
self.logger = logging.getLogger(__name__)
self.logger = get_logger(__name__)
self.registry_cache = None
self.registry_cache_time = None # Timestamp of when registry was cached
self.github_cache = {} # Cache for GitHub API responses
@@ -333,7 +333,7 @@ class PluginStoreManager:
try:
config_path = Path(__file__).parent.parent.parent / "config" / "config_secrets.json"
if config_path.exists():
with open(config_path, 'r') as f:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
token = config.get('github', {}).get('api_token', '').strip()
if token and token != "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN":
@@ -1528,7 +1528,7 @@ class PluginStoreManager:
'error': 'No manifest.json found in repository' + (f' at path: {plugin_path}' if plugin_path else '')
}
with open(manifest_path, 'r') as f:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
requested_id = plugin_id
@@ -1590,7 +1590,7 @@ class PluginStoreManager:
if 'entry_point' not in manifest:
manifest['entry_point'] = 'manager.py'
# Write updated manifest back to file
with open(manifest_path, 'w') as f:
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(manifest, f, indent=2)
self.logger.info(f"Added missing entry_point field to {plugin_id} manifest (defaulted to manager.py)")
@@ -1599,16 +1599,33 @@ class PluginStoreManager:
# manifest's id -- so it can differ from the manifest id, which
# discovery tolerates by reading the manifest.
final_path = self.plugins_dir / plugin_id
if final_path.exists():
self.logger.warning(f"Plugin {plugin_id} already exists, removing existing copy")
if not self._safe_remove_directory(final_path):
return {
'success': False,
'error': f'Failed to remove existing plugin directory: {final_path}'
}
shutil.move(str(temp_dir), str(final_path))
temp_dir = None # Prevent cleanup since we moved it
# Set the existing copy aside rather than deleting it, and put it
# back if the move fails: deleting first left the user with no
# plugin at all whenever the move broke part-way. Under the
# per-plugin reinstall lock, as install_plugin() is, so two
# overlapping installs of one id can't interleave their renames.
with self._get_reinstall_lock(plugin_id):
backup_path = None
if final_path.exists():
self.logger.warning(f"Plugin {plugin_id} already exists, replacing existing copy")
backup_path = final_path.with_name(
f"{final_path.name}{BACKUP_MARKER}preinstall")
problem = self._set_aside(final_path, backup_path)
if problem:
return {
'success': False,
'error': f'Failed to replace existing plugin directory: {problem}'
}
try:
shutil.move(str(temp_dir), str(final_path))
except Exception:
if backup_path is not None:
self._restore_backup(plugin_id, final_path, backup_path, "Install")
raise
temp_dir = None # Prevent cleanup since we moved it
if backup_path is not None:
self._discard_backup(plugin_id, backup_path, "install")
# Install dependencies
self._install_dependencies(final_path)