Files
LEDMatrix/src/plugin_system/state_manager.py
T
ChuckandClaude Opus 5.5 c00bf5e8e6 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>
2026-09-28 10:41:40 -04:00

344 lines
11 KiB
Python

"""
Centralized plugin state management.
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
with persistence.
"""
import json
import threading
from typing import Dict, Any, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from enum import Enum
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger
class PluginStateStatus(Enum):
"""Status of a plugin."""
INSTALLED = "installed"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class PluginState:
"""Represents the state of a plugin."""
plugin_id: str
status: PluginStateStatus
enabled: bool
version: Optional[str] = None
installed_at: Optional[datetime] = None
last_updated: Optional[datetime] = None
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
# plugin_state.json keeps the shape older releases load with cls(**data).
config_version: int = 1
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
def to_dict(self) -> Dict[str, Any]:
"""Convert state to dictionary for serialization."""
result = asdict(self)
# Convert enum to string
result['status'] = self.status.value
# Convert datetime to ISO string
if result.get('installed_at'):
result['installed_at'] = self.installed_at.isoformat()
if result.get('last_updated'):
result['last_updated'] = self.last_updated.isoformat()
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
"""Create state from dictionary."""
# Parse enum
if isinstance(data.get('status'), str):
data['status'] = PluginStateStatus(data['status'])
# Parse datetime
if data.get('installed_at') and isinstance(data['installed_at'], str):
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
if data.get('last_updated') and isinstance(data['last_updated'], str):
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
return cls(**data)
class PluginStateManager:
"""
Centralized plugin state manager.
Provides:
- Single source of truth for plugin state
- State persistence
"""
def __init__(
self,
state_file: Optional[str] = None,
auto_save: bool = True,
lazy_load: bool = False
):
"""
Initialize state manager.
Args:
state_file: Path to file for persisting state
auto_save: Whether to automatically save state on changes
lazy_load: If True, defer loading state file until first access
"""
self.logger = get_logger(__name__)
self.state_file = Path(state_file) if state_file else None
self.auto_save = auto_save
self._lazy_load = lazy_load
self._state_loaded = False
# State storage
self._states: Dict[str, PluginState] = {}
# The file's top-level "version", written back as read. Nothing
# checks it yet; it is there for a future format change to branch on.
self._state_version = 1
# Threading
self._lock = threading.RLock()
# Load state from file if it exists (unless lazy loading)
if not self._lazy_load and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def _ensure_loaded(self) -> None:
"""Ensure state is loaded (for lazy loading)."""
if not self._state_loaded and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
"""
Get state for a plugin.
Args:
plugin_id: Plugin identifier
Returns:
PluginState if found, None otherwise
"""
self._ensure_loaded()
with self._lock:
return self._states.get(plugin_id)
def get_all_states(self) -> Dict[str, PluginState]:
"""
Get all plugin states.
Returns:
Dictionary mapping plugin_id to PluginState
"""
self._ensure_loaded()
with self._lock:
return self._states.copy()
def update_plugin_state(
self,
plugin_id: str,
updates: Dict[str, Any]
) -> bool:
"""
Update plugin state.
Args:
plugin_id: Plugin identifier
updates: Dictionary of state updates
Returns:
True if update successful
"""
self._ensure_loaded()
with self._lock:
# Get current state or create new
current_state = self._states.get(plugin_id)
if not current_state:
current_state = PluginState(
plugin_id=plugin_id,
status=PluginStateStatus.UNKNOWN,
enabled=False
)
# Apply updates
if 'status' in updates:
if isinstance(updates['status'], str):
current_state.status = PluginStateStatus(updates['status'])
else:
current_state.status = updates['status']
if 'enabled' in updates:
current_state.enabled = bool(updates['enabled'])
if 'version' in updates:
current_state.version = updates['version']
if 'installed_at' in updates:
current_state.installed_at = updates['installed_at']
if 'last_updated' in updates:
current_state.last_updated = updates['last_updated']
else:
current_state.last_updated = datetime.now()
if 'metadata' in updates:
if current_state.metadata is None:
current_state.metadata = {}
current_state.metadata.update(updates['metadata'])
current_state.config_version += 1
# Store updated state
self._states[plugin_id] = current_state
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
"""
Set plugin enabled/disabled state.
Args:
plugin_id: Plugin identifier
enabled: Whether plugin is enabled
Returns:
True if update successful
"""
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
return self.update_plugin_state(
plugin_id,
{
'enabled': enabled,
'status': status
}
)
def set_plugin_installed(
self,
plugin_id: str,
version: Optional[str] = None,
installed_at: Optional[datetime] = None
) -> bool:
"""
Mark plugin as installed.
Args:
plugin_id: Plugin identifier
version: Plugin version
installed_at: Installation timestamp
Returns:
True if update successful
"""
return self.update_plugin_state(
plugin_id,
{
'status': PluginStateStatus.INSTALLED,
'version': version,
'installed_at': installed_at or datetime.now()
}
)
def remove_plugin_state(self, plugin_id: str) -> bool:
"""
Remove plugin state (e.g., after uninstall).
Args:
plugin_id: Plugin identifier
Returns:
True if removal successful
"""
self._ensure_loaded()
with self._lock:
if plugin_id in self._states:
del self._states[plugin_id]
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
return False
def _save_state(self) -> None:
"""Save state to file."""
if not self.state_file:
return
try:
# The write stays under the lock and goes through a temp file:
# Flask serves requests on threads, and two saves racing on a
# plain open('w') could interleave or leave a truncated file
# that _load_state then drops wholesale.
with self._lock:
# Convert states to dicts
states_data = {
plugin_id: state.to_dict()
for plugin_id, state in self._states.items()
}
state_data = {
'version': self._state_version,
'states': states_data,
'last_updated': datetime.now().isoformat()
}
# Ensure directory exists with proper permissions
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Write to file
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
except Exception as e:
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
def _load_state(self) -> None:
"""Load state from file."""
if not self.state_file or not self.state_file.exists():
return
try:
with open(self.state_file, 'r', encoding='utf-8') as f:
state_data = json.load(f)
with self._lock:
# Load state version
self._state_version = state_data.get('version', 1)
# Load states
states_data = state_data.get('states', {})
for plugin_id, state_dict in states_data.items():
try:
self._states[plugin_id] = PluginState.from_dict(state_dict)
except Exception as e:
self.logger.warning(
f"Error loading state for plugin {plugin_id}: {e}"
)
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
except Exception as e:
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)