fix(plugin-system): load/enable failures, atomic state files, pip lock, test-double parity (#645)

- load_plugin: an on_enable() that raises unregisters the instance, so the
  next load retries instead of returning True "already loaded".
- get_plugin_info: guard plugin.get_info(); one plugin raising no longer
  breaks /api/v3/plugins/installed.
- plugin_state.json and the operation history are written with
  atomic_write_text under their lock.
- plugin_loader: module-level lock serialises pip installs across the
  parallel startup loaders.
- store_manager._install_via_download: extract dir cleanup moved to finally.
- Test doubles: draw_image() warns (DeprecationWarning; the real
  DisplayManager has none), MockDisplayManager.draw_text accepts the real
  signature's optional params, VisualTestDisplayManager logs draw errors at
  WARNING.
- Docs/comments: compatibility.py method name, PluginState.LOADED meaning,
  brittle schema count, why _report_skip_once uses setdefault.
- Remove unused PluginOperationQueue.get_active_operations().

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 08:25:45 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6bc13a8934
commit b518c51679
16 changed files with 507 additions and 130 deletions
+8
View File
@@ -37,6 +37,14 @@ accepts both, but the store flags the old spelling as deprecated
- `check_system_compatibility.sh` no longer reports installed packages as missing. - `check_system_compatibility.sh` no longer reports installed packages as missing.
- A network failure fetching GitHub repo info logs a warning, not an error. - A network failure fetching GitHub repo info logs a warning, not an error.
- Plugin system:
- A plugin whose `on_enable()` raises is no longer left registered: the next load retries it instead of reporting "already loaded" for a plugin that never ran.
- One plugin's `get_info()` raising no longer breaks the installed-plugins list; it is logged and shown with empty runtime info.
- `plugin_state.json` and the operation history are written atomically (temp file + rename) under their lock, so concurrent saves or a failed save can't leave a truncated file.
- Plugin dependency installs run one `pip` at a time during parallel startup loading.
- A failed store download no longer leaves its extraction directory in the temp dir.
- Test doubles: `draw_image()` on `MockDisplayManager`, `VisualTestDisplayManager` and `BoundsCheckingDisplayManager` now emits a `DeprecationWarning` — the real `DisplayManager` has no such method; use `display_manager.image.paste(img, (x, y))`. `MockDisplayManager.draw_text` accepts the real signature's `small_font`/`centered` and default `x`/`y`, and `VisualTestDisplayManager` logs draw errors at WARNING.
- Removed the unused `PluginOperationQueue.get_active_operations()`.
- Display runtime: - Display runtime:
- Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart. - Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart.
- A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute. - A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute.
+1 -1
View File
@@ -193,7 +193,7 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
"""The core version this plugin says it needs, or ``None`` if it doesn't say. """The core version this plugin says it needs, or ``None`` if it doesn't say.
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags of `ledmatrix_min_version` (`store_manager._validate_manifest_version_fields` flags
it); both are read because a large share of published manifests still carry it); both are read because a large share of published manifests still carry
the old one. the old one.
+8 -6
View File
@@ -11,6 +11,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -180,15 +181,16 @@ class OperationHistory:
return return
try: try:
# Held across the write, and written via a temp file, so two
# threads saving at once can't interleave or truncate the file.
with self._lock: with self._lock:
history_data = [record.to_dict() for record in self._history] history_data = [record.to_dict() for record in self._history]
# Ensure directory exists # Ensure directory exists
self.history_file.parent.mkdir(parents=True, exist_ok=True) self.history_file.parent.mkdir(parents=True, exist_ok=True)
# Write to file # Write to file
with open(self.history_file, 'w') as f: atomic_write_text(self.history_file, json.dumps(history_data, indent=2))
json.dump(history_data, f, indent=2)
except Exception as e: except Exception as e:
self.logger.error(f"Error saving operation history: {e}", exc_info=True) self.logger.error(f"Error saving operation history: {e}", exc_info=True)
-14
View File
@@ -172,20 +172,6 @@ class PluginOperationQueue:
) )
return history[:limit] return history[:limit]
def get_active_operations(self) -> List[PluginOperation]:
"""
Get all currently active operations (pending or running).
Returns:
List of active operations
"""
with self._lock:
active = []
for operation in self._operations.values():
if operation.status in [OperationStatus.PENDING, OperationStatus.RUNNING]:
active.append(operation)
return active
def _start_worker(self) -> None: def _start_worker(self) -> None:
"""Start the worker thread that processes operations.""" """Start the worker thread that processes operations."""
if self._worker_thread and self._worker_thread.is_alive(): if self._worker_thread and self._worker_thread.is_alive():
+90 -84
View File
@@ -23,6 +23,11 @@ from src.exceptions import PluginError
from src.logging_config import get_logger from src.logging_config import get_logger
from src.plugin_system.plugin_dirs import resolve_plugin_dir from src.plugin_system.plugin_dirs import resolve_plugin_dir
#: Serialises pip runs across threads. Startup loads plugins on a small
#: thread pool, and two concurrent ``pip install`` processes writing the same
#: site-packages can corrupt it or fail on each other's partial installs.
_PIP_INSTALL_LOCK = threading.Lock()
def requirements_has_real_deps(requirements_file: str) -> bool: def requirements_has_real_deps(requirements_file: str) -> bool:
""" """
@@ -325,93 +330,94 @@ class PluginLoader:
if requirements_file is None: if requirements_file is None:
return True return True
try: with _PIP_INSTALL_LOCK:
self.logger.info("Installing dependencies for plugin %s...", plugin_id) try:
result = subprocess.run( self.logger.info("Installing dependencies for plugin %s...", plugin_id)
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file], result = subprocess.run(
capture_output=True, [sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
text=True, capture_output=True,
timeout=timeout, text=True,
check=False timeout=timeout,
) check=False
)
if result.returncode == 0: if result.returncode == 0:
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True
else:
stderr = result.stderr or ""
# uninstall-no-record-file means a system-managed copy of a package
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
# the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is rebuilt
# by contained_plugin_dir() from a trusted listing, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
return True return True
self.logger.warning( else:
"Dependency installation returned non-zero exit code for %s: %s", stderr = result.stderr or ""
plugin_id, # uninstall-no-record-file means a system-managed copy of a package
stderr # (e.g. apt's python3-requests, which ships no pip RECORD file) is in
) # the way of the version this requirements.txt pins. Retry with
# --ignore-installed so pip lays the pinned version down alongside
# the system copy instead of trying to replace it — matching the
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
# Without this retry, the plugin would silently keep running against
# whatever version the system happened to ship.
if "uninstall-no-record-file" in stderr:
self.logger.warning(
"Dependencies for %s conflict with a system-managed package "
"(no pip RECORD); retrying with --ignore-installed: %s",
plugin_id, stderr.strip()
)
# Wrapped in its own try/except so a retry timeout is
# tolerated the same way as a retry failure, instead of
# propagating to the outer handler and returning False
# (which would contradict the "assume satisfied" fallback
# below).
try:
# sys.executable is this process's own interpreter (not
# attacker-influenced), and requirements_file is rebuilt
# by contained_plugin_dir() from a trusted listing, never raw
# external input.
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages",
"--ignore-installed", "-r", requirements_file],
capture_output=True,
text=True,
timeout=timeout,
check=False
)
if retry_result.returncode != 0:
self.logger.warning(
"Retry with --ignore-installed also failed for %s; assuming the "
"system-managed version satisfies the requirement: %s",
plugin_id, (retry_result.stderr or "").strip()
)
except subprocess.TimeoutExpired:
self.logger.warning(
"Retry with --ignore-installed timed out for %s; assuming the "
"system-managed version satisfies the requirement",
plugin_id
)
return True
self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s",
plugin_id,
stderr
)
return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out for %s", plugin_id)
return False
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
return True
except OSError as e:
# A broken pipe (EPIPE) happens when pip's output pipe closes
# mid-download, usually a network interruption.
if e.errno == errno.EPIPE:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection.", plugin_id
)
else:
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False return False
except subprocess.TimeoutExpired:
self.logger.error("Dependency installation timed out for %s", plugin_id)
return False
except FileNotFoundError:
self.logger.warning("pip not found. Skipping dependency installation for %s", plugin_id)
return True
except OSError as e:
# A broken pipe (EPIPE) happens when pip's output pipe closes
# mid-download, usually a network interruption.
if e.errno == errno.EPIPE:
self.logger.error(
"Broken pipe error during dependency installation for %s. "
"This usually indicates a network interruption or pip output buffer issue. "
"Try installing again or check your network connection.", plugin_id
)
else:
self.logger.error("OS error during dependency installation for %s: %s", plugin_id, e)
return False
except Exception as e:
self.logger.error("Unexpected error installing dependencies for %s: %s", plugin_id, e, exc_info=True)
return False
@staticmethod @staticmethod
def _iter_plugin_bare_modules( def _iter_plugin_bare_modules(
+22 -3
View File
@@ -183,6 +183,8 @@ class PluginManager:
someone opened a page -- the same log-volume problem this is meant to someone opened a page -- the same log-volume problem this is meant to
help diagnose. help diagnose.
""" """
# setdefault rather than self._skip_reported: tests build a bare
# scanner with PluginManager.__new__ and skip __init__.
reported = self.__dict__.setdefault('_skip_reported', set()) reported = self.__dict__.setdefault('_skip_reported', set())
if key in reported: if key in reported:
return return
@@ -433,7 +435,18 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ENABLED) self.state_manager.set_state(plugin_id, PluginState.ENABLED)
# Call on_enable if plugin is enabled # Call on_enable if plugin is enabled
if hasattr(plugin_instance, 'on_enable'): if hasattr(plugin_instance, 'on_enable'):
plugin_instance.on_enable() try:
plugin_instance.on_enable()
except Exception:
# Undo the registration above before the outer
# handler marks it ERROR: left in self.plugins, the
# next load_plugin() would return True as "already
# loaded" for a plugin that never enabled.
self.plugins.pop(plugin_id, None)
with self._plugin_last_update_lock:
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None)
raise
else: else:
self.state_manager.set_state(plugin_id, PluginState.DISABLED) self.state_manager.set_state(plugin_id, PluginState.DISABLED)
@@ -452,7 +465,7 @@ class PluginManager:
#: Config keys the **core** reads out of a plugin's own config block. The #: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with #: plugin never declares them, so a schema with
#: ``"additionalProperties": false`` — 37 of the 42 published ones — reports #: ``"additionalProperties": false`` — most published ones do — reports
#: them as violations and the plugin gets flagged degraded in the web UI for #: them as violations and the plugin gets flagged degraded in the web UI for
#: using a documented core feature. #: using a documented core feature.
#: #:
@@ -744,7 +757,13 @@ class PluginManager:
if plugin: if plugin:
info['loaded'] = True info['loaded'] = True
if hasattr(plugin, 'get_info'): if hasattr(plugin, 'get_info'):
info['runtime_info'] = plugin.get_info() # One plugin's get_info() raising must not take down the
# whole installed-plugins listing (/api/v3/plugins/installed).
try:
info['runtime_info'] = plugin.get_info()
except Exception as e:
self.logger.warning("Plugin %s get_info() failed: %s", plugin_id, e)
info['runtime_info'] = {}
else: else:
info['loaded'] = False info['loaded'] = False
+1 -1
View File
@@ -17,7 +17,7 @@ from src.logging_config import get_logger
class PluginState(Enum): class PluginState(Enum):
"""Plugin state enumeration.""" """Plugin state enumeration."""
UNLOADED = "unloaded" # Plugin not loaded UNLOADED = "unloaded" # Plugin not loaded
LOADED = "loaded" # Plugin module loaded but not instantiated LOADED = "loaded" # load_plugin() in progress: set before the module is imported
ENABLED = "enabled" # Plugin instantiated and enabled ENABLED = "enabled" # Plugin instantiated and enabled
RUNNING = "running" # Plugin is currently executing RUNNING = "running" # Plugin is currently executing
ERROR = "error" # Plugin encountered an error ERROR = "error" # Plugin encountered an error
+14 -10
View File
@@ -13,6 +13,7 @@ from datetime import datetime
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from enum import Enum from enum import Enum
from src.config_manager_atomic import atomic_write_text
from src.logging_config import get_logger from src.logging_config import get_logger
@@ -283,6 +284,10 @@ class PluginStateManager:
return return
try: 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: with self._lock:
# Convert states to dicts # Convert states to dicts
states_data = { states_data = {
@@ -296,16 +301,15 @@ class PluginStateManager:
'last_updated': datetime.now().isoformat() 'last_updated': datetime.now().isoformat()
} }
# Ensure directory exists with proper permissions # Ensure directory exists with proper permissions
from src.common.permission_utils import ( from src.common.permission_utils import (
ensure_directory_permissions, ensure_directory_permissions,
get_config_dir_mode get_config_dir_mode
) )
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode()) ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Write to file # Write to file
with open(self.state_file, 'w') as f: atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
json.dump(state_data, f, indent=2)
except Exception as e: except Exception as e:
self.logger.error(f"Error saving plugin state: {e}", exc_info=True) self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
+7 -5
View File
@@ -1994,6 +1994,7 @@ class PluginStoreManager:
tmp_file.write(chunk) tmp_file.write(chunk)
tmp_zip_path = tmp_file.name tmp_zip_path = tmp_file.name
temp_extract = None
try: try:
# Extract zip # Extract zip
with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref: with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref:
@@ -2015,7 +2016,6 @@ class PluginStoreManager:
f"Zip-slip detected: member {member!r} resolves outside " f"Zip-slip detected: member {member!r} resolves outside "
f"temp directory, aborting" f"temp directory, aborting"
) )
shutil.rmtree(temp_extract, ignore_errors=True)
return False return False
zip_ref.extractall(temp_extract) zip_ref.extractall(temp_extract)
@@ -2027,10 +2027,6 @@ class PluginStoreManager:
else: else:
# No root dir, move everything # No root dir, move everything
shutil.move(str(temp_extract), str(target_path)) shutil.move(str(temp_extract), str(target_path))
# Cleanup temp extract dir
if temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
return True return True
@@ -2038,6 +2034,12 @@ class PluginStoreManager:
# Remove temporary zip file # Remove temporary zip file
if os.path.exists(tmp_zip_path): if os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path) os.remove(tmp_zip_path)
# Cleanup temp extract dir here rather than on the success
# path, so a failed extract or move doesn't leave a copy of
# the plugin in /tmp on every attempt. (Gone already when the
# whole dir was moved into place.)
if temp_extract is not None and temp_extract.exists():
shutil.rmtree(temp_extract, ignore_errors=True)
except Exception as e: except Exception as e:
self.logger.error(f"Download failed: {e}") self.logger.error(f"Download failed: {e}")
+24 -3
View File
@@ -5,9 +5,21 @@ Provides mock implementations of display_manager, cache_manager, config_manager,
and plugin_manager for use in plugin unit tests. and plugin_manager for use in plugin unit tests.
""" """
import warnings
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from PIL import Image from PIL import Image
#: Why draw_image() warns. Kept (rather than removed) so existing plugin test
#: suites that call it keep passing, but a plugin that calls it passes its
#: tests and then crashes on the Pi.
DRAW_IMAGE_DEPRECATION = (
"display_manager.draw_image() exists only on the test doubles; the real "
"DisplayManager has no such method, so this raises AttributeError on a "
"device. Paste onto the canvas instead: "
"display_manager.image.paste(img, (x, y)) (with the image as mask, "
"image.paste(rgba, (x, y), rgba), for transparency)."
)
class MockDisplayManager: class MockDisplayManager:
"""Mock display manager for testing.""" """Mock display manager for testing."""
@@ -31,8 +43,16 @@ class MockDisplayManager:
"""Update the display.""" """Update the display."""
self.update_called = True self.update_called = True
def draw_text(self, text: str, x: int, y: int, color: tuple = (255, 255, 255), font=None): def draw_text(self, text: str, x: int = None, y: int = None, color: tuple = (255, 255, 255),
"""Draw text on the display.""" font=None, small_font: bool = False, centered: bool = False):
"""Draw text on the display.
Accepts every argument the real ``DisplayManager.draw_text`` does, so
a plugin passing ``small_font``/``centered`` (or leaving x/y to
default) doesn't fail here while working on the device. ``font``
stays fifth for callers of the old mock signature; pass the rest by
keyword, as the real method's positional order differs.
"""
self.draw_calls.append({ self.draw_calls.append({
'type': 'text', 'type': 'text',
'text': text, 'text': text,
@@ -43,7 +63,8 @@ class MockDisplayManager:
}) })
def draw_image(self, image: Image.Image, x: int, y: int): def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display.""" """Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
self.draw_calls.append({ self.draw_calls.append({
'type': 'image', 'type': 'image',
'image': image, 'image': image,
@@ -29,6 +29,7 @@ through src/common/bdf_font.py, so those pixels cannot drift.
import math import math
import os import os
import time import time
import warnings
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
@@ -38,9 +39,12 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face
from src.common.font_layout import crisp_size, load_truetype from src.common.font_layout import crisp_size, load_truetype
from src.logging_config import get_logger from src.logging_config import get_logger
from src.plugin_system.testing.mocks import DRAW_IMAGE_DEPRECATION
logger = get_logger(__name__) logger = get_logger(__name__)
_draw_image_warning_logged = False
class _MatrixProxy: class _MatrixProxy:
"""Lightweight proxy so plugins can access display_manager.matrix.width/height.""" """Lightweight proxy so plugins can access display_manager.matrix.width/height."""
@@ -321,17 +325,26 @@ class VisualTestDisplayManager:
else: else:
self.draw.text((x, y), text, font=current_font, fill=color) self.draw.text((x, y), text, font=current_font, fill=color)
except Exception as e: except Exception as e:
logger.debug(f"Error drawing text: {e}") # WARNING, not DEBUG: the real DisplayManager logs this at ERROR,
# and a test double that hides it lets a broken draw pass.
logger.warning(f"Error drawing text: {e}")
def draw_image(self, image: Image.Image, x: int, y: int): def draw_image(self, image: Image.Image, x: int, y: int):
"""Draw an image on the display.""" """Draw an image on the display. Deprecated: see DRAW_IMAGE_DEPRECATION."""
warnings.warn(DRAW_IMAGE_DEPRECATION, DeprecationWarning, stacklevel=2)
global _draw_image_warning_logged
if not _draw_image_warning_logged:
# Also logged once: the dev preview server drives this class
# outside pytest, where DeprecationWarning is hidden by default.
_draw_image_warning_logged = True
logger.warning(DRAW_IMAGE_DEPRECATION)
self.draw_calls.append({ self.draw_calls.append({
'type': 'image', 'image': image, 'x': x, 'y': y, 'type': 'image', 'image': image, 'x': x, 'y': y,
}) })
try: try:
self.image.paste(image, (x, y)) self.image.paste(image, (x, y))
except Exception as e: except Exception as e:
logger.debug(f"Error drawing image: {e}") logger.warning(f"Error drawing image: {e}")
def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None): def _draw_bdf_text(self, text, x, y, color=(255, 255, 255), font=None):
"""Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left. """Draw text in a BDF ``freetype.Face`` with (x, y) as its top-left.
+66
View File
@@ -0,0 +1,66 @@
"""_install_via_download removes its extraction directory on failure too.
The temp extract dir was only removed on the success path (and on a zip-slip
abort), so an extract or move that raised left a full copy of the plugin in
the system temp dir on every failed attempt. Cleanup now lives in ``finally``.
"""
import io
import tempfile
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from src.plugin_system.store_manager import PluginStoreManager
def _zip_bytes():
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('demo-main/manifest.json', '{"id": "demo"}')
zf.writestr('demo-main/manager.py', '')
return buf.getvalue()
def _response(payload):
response = MagicMock()
response.raise_for_status.return_value = None
response.iter_content.return_value = [payload]
return response
def _run(tmp_path, move_side_effect=None):
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
sm = PluginStoreManager(plugins_dir=str(plugins_dir))
created = []
real_mkdtemp = tempfile.mkdtemp
def tracking_mkdtemp(*args, **kwargs):
path = real_mkdtemp(*args, **kwargs)
created.append(Path(path))
return path
with patch.object(sm, '_http_get_with_retries', return_value=_response(_zip_bytes())), \
patch('src.plugin_system.store_manager.tempfile.mkdtemp', side_effect=tracking_mkdtemp), \
patch('src.plugin_system.store_manager.shutil.move', side_effect=move_side_effect):
ok = sm._install_via_download('https://example.invalid/demo.zip', plugins_dir / 'demo')
return ok, created
def test_extract_dir_removed_when_the_move_fails(tmp_path):
ok, created = _run(tmp_path, move_side_effect=OSError("disk full"))
assert ok is False
assert len(created) == 1
assert not created[0].exists()
def test_extract_dir_removed_on_success(tmp_path):
# shutil.move patched to a no-op: the extracted tree stays in temp and
# must still be cleaned up.
ok, created = _run(tmp_path, move_side_effect=lambda *a, **k: None)
assert ok is True
assert len(created) == 1
assert not created[0].exists()
+46
View File
@@ -342,3 +342,49 @@ class TestPluginLoader:
assert result is False assert result is False
mock_subprocess.assert_not_called() mock_subprocess.assert_not_called()
@patch('src.plugin_system.plugin_loader.requirements_are_satisfied', return_value=False)
def test_install_dependencies_never_runs_pip_concurrently(
self, mock_satisfied, tmp_plugins_dir
):
"""Startup loads plugins on a thread pool; two pip processes writing
the same site-packages at once can corrupt it, so installs for
different plugins must run one at a time."""
import threading
import time
state = {'running': 0, 'peak': 0}
guard = threading.Lock()
def fake_pip(*args, **kwargs):
with guard:
state['running'] += 1
state['peak'] = max(state['peak'], state['running'])
time.sleep(0.05)
with guard:
state['running'] -= 1
return MagicMock(returncode=0, stderr="")
plugin_dirs = []
for n in range(4):
d = tmp_plugins_dir / f"plugin_{n}"
d.mkdir()
(d / "requirements.txt").write_text("package1==1.0.0\n")
plugin_dirs.append(d)
results = []
def install(d):
results.append(PluginLoader().install_dependencies(
d, d.name, plugins_dir=tmp_plugins_dir))
with patch('subprocess.run', side_effect=fake_pip) as mock_subprocess:
threads = [threading.Thread(target=install, args=(d,)) for d in plugin_dirs]
for t in threads:
t.start()
for t in threads:
t.join()
assert results == [True] * 4
assert mock_subprocess.call_count == 4
assert state['peak'] == 1
+84
View File
@@ -0,0 +1,84 @@
"""PluginManager failure paths that used to leave the manager inconsistent.
- load_plugin registered the instance in ``self.plugins`` before calling
``on_enable()``. When on_enable raised, the plugin stayed registered in
ERROR state, so the next load_plugin returned True ("already loaded") for
a plugin that never ran.
- get_plugin_info called ``plugin.get_info()`` unguarded, so one plugin
raising broke /api/v3/plugins/installed for every plugin.
"""
import logging
from unittest.mock import MagicMock
import pytest
from src.plugin_system.plugin_manager import PluginManager
from src.plugin_system.plugin_state import PluginState
class _Plugin:
def __init__(self, fail_enable=False, fail_info=False):
self.fail_enable = fail_enable
self.fail_info = fail_info
self.enabled_calls = 0
def on_enable(self):
self.enabled_calls += 1
if self.fail_enable:
raise RuntimeError("on_enable blew up")
def get_info(self):
if self.fail_info:
raise RuntimeError("get_info blew up")
return {"ok": True}
@pytest.fixture
def pm(tmp_path):
plugins_dir = tmp_path / "plugins"
(plugins_dir / "demo").mkdir(parents=True)
manager = PluginManager(plugins_dir=str(plugins_dir))
manager.plugin_manifests["demo"] = {"id": "demo", "name": "Demo"}
manager.schema_manager = MagicMock()
manager.schema_manager.get_schema_path.return_value = None
manager.plugin_loader = MagicMock()
manager.plugin_loader.find_plugin_directory.return_value = plugins_dir / "demo"
return manager
def test_on_enable_failure_unregisters_the_plugin(pm):
plugin = _Plugin(fail_enable=True)
pm.plugin_loader.load_plugin.return_value = (plugin, None)
assert pm.load_plugin("demo") is False
assert "demo" not in pm.plugins
assert "demo" not in pm.plugin_last_update
assert pm.state_manager.get_state("demo") == PluginState.ERROR
def test_load_after_on_enable_failure_retries_instead_of_already_loaded(pm):
pm.plugin_loader.load_plugin.return_value = (_Plugin(fail_enable=True), None)
assert pm.load_plugin("demo") is False
fixed = _Plugin()
pm.plugin_loader.load_plugin.return_value = (fixed, None)
assert pm.load_plugin("demo") is True
assert pm.plugins["demo"] is fixed
assert fixed.enabled_calls == 1
assert pm.state_manager.get_state("demo") == PluginState.ENABLED
def test_get_info_failure_does_not_break_the_listing(pm, caplog):
pm.plugin_manifests["good"] = {"id": "good", "name": "Good"}
pm.plugins["demo"] = _Plugin(fail_info=True)
pm.plugins["good"] = _Plugin()
with caplog.at_level(logging.WARNING):
infos = {i["id"]: i for i in pm.get_all_plugin_info()}
assert infos["demo"]["loaded"] is True
assert infos["demo"]["runtime_info"] == {}
assert infos["good"]["runtime_info"] == {"ok": True}
assert any("demo" in r.getMessage() and r.levelno == logging.WARNING
for r in caplog.records)
+57
View File
@@ -0,0 +1,57 @@
"""plugin_state.json and the operation history file are replaced atomically.
Both were written with a plain ``open(path, 'w')`` + ``json.dump`` outside
their lock. The open truncates first, so a value json can't encode (or a
crash, or a second Flask thread saving at the same moment) left a partial
file, and the next load dropped every saved state. They now serialise first
and go through a temp file + rename while holding the lock.
"""
import json
import threading
from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.state_manager import PluginStateManager
def test_state_file_survives_a_failed_save(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
mgr.set_plugin_enabled("clock", True)
before = json.loads(state_file.read_text())
# Not JSON-serialisable: the save fails (and is logged, not raised).
mgr.update_plugin_state("clock", {"metadata": {"bad": object()}})
assert json.loads(state_file.read_text()) == before
assert [p.name for p in tmp_path.iterdir()] == ["plugin_state.json"]
def test_state_file_is_valid_after_concurrent_saves(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
def worker(n):
for i in range(15):
mgr.set_plugin_enabled(f"plugin-{n}", i % 2 == 0)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
data = json.loads(state_file.read_text())
assert set(data["states"]) == {f"plugin-{n}" for n in range(6)}
def test_history_file_survives_a_failed_save(tmp_path):
history_file = tmp_path / "operation_history.json"
history = OperationHistory(history_file=str(history_file))
history.record_operation("install", plugin_id="clock")
before = json.loads(history_file.read_text())
history.record_operation("update", plugin_id="clock", details={"bad": object()})
assert json.loads(history_file.read_text()) == before
assert [p.name for p in tmp_path.iterdir()] == ["operation_history.json"]
+63
View File
@@ -9,6 +9,8 @@ get_cached_data_with_strategy() and previously hit an AttributeError that
its own broad except swallowed, producing an empty-but-green render). its own broad except swallowed, producing an empty-but-green render).
""" """
import pytest
from src.plugin_system.testing.mocks import MockCacheManager from src.plugin_system.testing.mocks import MockCacheManager
@@ -43,3 +45,64 @@ class TestMockCacheManagerStrategyMethod:
cm.get_cached_data_with_strategy("k", "sports_live") cm.get_cached_data_with_strategy("k", "sports_live")
cm.reset() cm.reset()
assert cm.get_cached_data_with_strategy_calls == [] assert cm.get_cached_data_with_strategy_calls == []
class TestDisplayDoublesMatchTheRealDisplayManager:
"""The real DisplayManager has no draw_image(); the doubles keep it so
existing plugin test suites still pass, but warn, because a plugin that
calls it passes its tests and then raises AttributeError on the Pi."""
@staticmethod
def _real_display_manager(monkeypatch):
# Without EMULATOR the import needs the hardware rgbmatrix module.
monkeypatch.setenv("EMULATOR", "true")
from src.display_manager import DisplayManager
return DisplayManager
def test_real_display_manager_has_no_draw_image(self, monkeypatch):
DisplayManager = self._real_display_manager(monkeypatch)
assert not hasattr(DisplayManager, 'draw_image')
def test_mock_draw_image_warns_and_still_records(self):
from PIL import Image
from src.plugin_system.testing.mocks import MockDisplayManager
dm = MockDisplayManager()
with pytest.warns(DeprecationWarning, match=r"image\.paste\(img, \(x, y\)\)"):
dm.draw_image(Image.new('RGB', (4, 4)), 1, 2)
assert dm.draw_calls[-1]['type'] == 'image'
@pytest.mark.parametrize("cls_name", ["VisualTestDisplayManager", "BoundsCheckingDisplayManager"])
def test_visual_draw_image_warns_and_still_pastes(self, cls_name):
from PIL import Image
import src.plugin_system.testing as testing
dm = getattr(testing, cls_name)(width=16, height=8)
with pytest.warns(DeprecationWarning, match="DisplayManager has no such method"):
dm.draw_image(Image.new('RGB', (2, 2), (0, 0, 255)), 3, 3)
assert dm.image.getpixel((3, 3)) == (0, 0, 255)
def test_mock_draw_text_accepts_the_real_signature(self, monkeypatch):
import inspect
DisplayManager = self._real_display_manager(monkeypatch)
from src.plugin_system.testing.mocks import MockDisplayManager
real = inspect.signature(DisplayManager.draw_text).parameters
mock = inspect.signature(MockDisplayManager.draw_text).parameters
for name, param in real.items():
assert name in mock, name
assert mock[name].default == param.default, name
dm = MockDisplayManager()
dm.draw_text("hi", small_font=True, centered=True)
assert dm.draw_calls[-1]['text'] == "hi"
def test_visual_draw_failure_is_logged_at_warning(self, caplog):
import logging
from src.plugin_system.testing import VisualTestDisplayManager
dm = VisualTestDisplayManager(width=16, height=8)
with caplog.at_level(logging.WARNING):
dm.draw_image(object(), 0, 0) # not an image: paste raises
assert any(r.levelno == logging.WARNING and "Error drawing image" in r.getMessage()
for r in caplog.records)