mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user