mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782731052d | ||
|
|
641296990d |
+7
-34
@@ -33,8 +33,7 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from typing import Dict, Any, List, Optional
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import freetype
|
import freetype
|
||||||
@@ -181,25 +180,14 @@ class DisplayManager:
|
|||||||
# the logical image is blitted to the matrix unchanged.
|
# the logical image is blitted to the matrix unchanged.
|
||||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||||
self._physical_image = None # full-chain buffer reused each frame when tiling
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
|
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||||
# Avoids re-measuring the same string+font on every display() call.
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
|
|
||||||
# live score) would otherwise grow it forever on a 24/7 service.
|
|
||||||
# Entries hold a strong reference to the font so its id() can't be
|
|
||||||
# recycled by a different font object — an id-keyed cache without
|
|
||||||
# the reference can return the WRONG width after garbage collection.
|
|
||||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
|
self._text_width_cache: Dict[tuple, int] = {}
|
||||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
|
||||||
# Snapshot settings for web preview integration (service writes, web reads)
|
# Snapshot settings for web preview integration (service writes, web reads)
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
# Snapshot failures are logged as warnings, rate-limited so a
|
|
||||||
# persistent failure (e.g. an unwritable file) can't spam the log —
|
|
||||||
# but is never silent: the snapshot's mtime doubles as the web UI's
|
|
||||||
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
|
||||||
self._snapshot_fail_log_ts = 0.0
|
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -711,15 +699,12 @@ class DisplayManager:
|
|||||||
|
|
||||||
Results are cached by (text, font identity) so plugins that measure
|
Results are cached by (text, font identity) so plugins that measure
|
||||||
the same string every frame (e.g. to centre a score) pay only one
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
measurement per unique (text, font) pair. The entry keeps the font
|
measurement per unique (text, font) pair.
|
||||||
alive so its id() can't be recycled, and the cache is LRU-bounded so
|
|
||||||
ever-changing text (clocks, tickers) can't grow it without limit.
|
|
||||||
"""
|
"""
|
||||||
cache_key = (text, id(font))
|
cache_key = (text, id(font))
|
||||||
cached = self._text_width_cache.get(cache_key)
|
cached = self._text_width_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
self._text_width_cache.move_to_end(cache_key)
|
return cached
|
||||||
return cached[0]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -734,9 +719,7 @@ class DisplayManager:
|
|||||||
logger.error("Error getting text width: %s", e)
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
self._text_width_cache[cache_key] = (width, font)
|
self._text_width_cache[cache_key] = width
|
||||||
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
|
|
||||||
self._text_width_cache.popitem(last=False)
|
|
||||||
return width
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
@@ -1181,15 +1164,5 @@ class DisplayManager:
|
|||||||
pass
|
pass
|
||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Snapshot failures must never break display — but they must not
|
# Snapshot failures should never break display; log at debug to avoid noise
|
||||||
# be silent either: the snapshot's mtime is the web UI's display
|
|
||||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
|
||||||
# write freezes the mirror and makes health checks lie (seen in
|
|
||||||
# the field: a stale root-owned /tmp file froze it for a day).
|
|
||||||
# Warn at most once per 5 minutes to avoid log spam.
|
|
||||||
if (now - self._snapshot_fail_log_ts) > 300:
|
|
||||||
self._snapshot_fail_log_ts = now
|
|
||||||
logger.warning("Snapshot write failing (web preview/health "
|
|
||||||
"mirror is stale): %s", e)
|
|
||||||
else:
|
|
||||||
logger.debug(f"Snapshot write skipped: {e}")
|
logger.debug(f"Snapshot write skipped: {e}")
|
||||||
+5
-18
@@ -35,7 +35,6 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
@@ -59,13 +58,7 @@ class FontManager:
|
|||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||||
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||||
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
|
||||||
# (clocks, live scores) would otherwise grow it forever. Entries
|
|
||||||
# keep the font alive so its id() can't be recycled by a different
|
|
||||||
# font object (which would silently return wrong metrics).
|
|
||||||
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
|
||||||
self._METRICS_CACHE_MAX = 1024
|
|
||||||
|
|
||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||||
@@ -514,14 +507,10 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (width, height, baseline_offset)
|
Tuple of (width, height, baseline_offset)
|
||||||
"""
|
"""
|
||||||
# Key on the text itself (hash(text) could collide) + font identity;
|
cache_key = f"{hash(text)}_{id(font)}"
|
||||||
# the entry below keeps the font referenced so the id stays valid.
|
|
||||||
cache_key = (text, id(font))
|
|
||||||
|
|
||||||
cached = self.metrics_cache.get(cache_key)
|
if cache_key in self.metrics_cache:
|
||||||
if cached is not None:
|
return self.metrics_cache[cache_key]
|
||||||
self.metrics_cache.move_to_end(cache_key)
|
|
||||||
return cached[0]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -558,9 +547,7 @@ class FontManager:
|
|||||||
baseline = 10
|
baseline = 10
|
||||||
|
|
||||||
result = (width, height, baseline)
|
result = (width, height, baseline)
|
||||||
self.metrics_cache[cache_key] = (result, font)
|
self.metrics_cache[cache_key] = result
|
||||||
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
|
||||||
self.metrics_cache.popitem(last=False)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||||
|
|||||||
@@ -142,9 +142,28 @@ class PluginStoreManager:
|
|||||||
# then get the result from the warm cache (double-checked locking).
|
# then get the result from the warm cache (double-checked locking).
|
||||||
self._registry_fetch_lock = threading.Lock()
|
self._registry_fetch_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
|
||||||
|
# Flask with threaded=True, so two overlapping requests for the
|
||||||
|
# same plugin_id (double-click, two browser tabs) would otherwise
|
||||||
|
# both rename the same directory aside — one succeeds, and the
|
||||||
|
# loser can end up renaming the winner's in-progress install aside
|
||||||
|
# mid-download, stealing its own rollback safety net. Keyed by
|
||||||
|
# plugin_id so unrelated plugins still update concurrently.
|
||||||
|
self._reinstall_locks: Dict[str, threading.Lock] = {}
|
||||||
|
self._reinstall_locks_guard = threading.Lock()
|
||||||
|
|
||||||
# Ensure plugins directory exists
|
# Ensure plugins directory exists
|
||||||
self.plugins_dir.mkdir(exist_ok=True)
|
self.plugins_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def _get_reinstall_lock(self, plugin_id: str) -> threading.Lock:
|
||||||
|
"""Lazily create (or fetch) the per-plugin reinstall lock."""
|
||||||
|
with self._reinstall_locks_guard:
|
||||||
|
lock = self._reinstall_locks.get(plugin_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.Lock()
|
||||||
|
self._reinstall_locks[plugin_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
|
||||||
cache_timeout: int, payload: Any) -> None:
|
cache_timeout: int, payload: Any) -> None:
|
||||||
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
"""Bump a cache entry's timestamp so subsequent lookups hit the
|
||||||
@@ -2263,6 +2282,74 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||||
|
"""Replace an installed plugin with a fresh install, atomically.
|
||||||
|
|
||||||
|
The old install is renamed aside (not deleted) until the new install
|
||||||
|
succeeds, then removed; on ANY install failure the old directory is
|
||||||
|
restored. This is the difference between a failed update and a
|
||||||
|
destroyed plugin: the previous delete-then-install flow permanently
|
||||||
|
removed plugins whenever the download failed mid-update (seen in the
|
||||||
|
field during the monorepo migration on a Pi with broken DNS — every
|
||||||
|
old-remote plugin was deleted and none could be re-downloaded).
|
||||||
|
|
||||||
|
The aside name embeds '.standalone-backup-' so plugin discovery
|
||||||
|
(plugin_manager._scan_directory_for_plugins) ignores it even though
|
||||||
|
it still contains a manifest.json.
|
||||||
|
|
||||||
|
Held for the whole operation under a per-plugin_id lock: two
|
||||||
|
overlapping requests for the same plugin (double-click, two
|
||||||
|
browser tabs — the web UI runs Flask with threaded=True) must not
|
||||||
|
interleave their renames, or the second could steal the first's
|
||||||
|
rollback safety net mid-install. Other plugin_ids are unaffected.
|
||||||
|
"""
|
||||||
|
with self._get_reinstall_lock(plugin_id):
|
||||||
|
backup_path = plugin_path.with_name(
|
||||||
|
f"{plugin_path.name}.standalone-backup-migrating")
|
||||||
|
# A stale aside from a previous crash would block the rename
|
||||||
|
if backup_path.exists():
|
||||||
|
if not self._safe_remove_directory(backup_path):
|
||||||
|
self.logger.error(
|
||||||
|
f"Could not clear stale backup for {plugin_id} at "
|
||||||
|
f"{backup_path}; leaving old install in place")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
plugin_path.rename(backup_path)
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Could not set aside old plugin directory for {plugin_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
installed = self.install_plugin(plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
|
||||||
|
installed = False
|
||||||
|
|
||||||
|
if installed:
|
||||||
|
if not self._safe_remove_directory(backup_path):
|
||||||
|
self.logger.warning(
|
||||||
|
f"Update of {plugin_id} succeeded but the old backup "
|
||||||
|
f"at {backup_path} could not be removed; it will be "
|
||||||
|
f"cleared on the next update")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Install failed (bad network, registry error...) — put the old
|
||||||
|
# version back so the user still has a working plugin.
|
||||||
|
self.logger.error(
|
||||||
|
f"Reinstall of {plugin_id} failed; restoring previous version")
|
||||||
|
try:
|
||||||
|
if plugin_path.exists():
|
||||||
|
# partial download debris from the failed install
|
||||||
|
self._safe_remove_directory(plugin_path)
|
||||||
|
backup_path.rename(plugin_path)
|
||||||
|
self.logger.info(f"Restored previous install of {plugin_id}")
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
|
||||||
|
f"The previous install is preserved there — rename it back manually.")
|
||||||
|
return False
|
||||||
|
|
||||||
def update_plugin(self, plugin_id: str) -> bool:
|
def update_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update a plugin to the latest commit on its upstream branch.
|
Update a plugin to the latest commit on its upstream branch.
|
||||||
@@ -2325,10 +2412,7 @@ class PluginStoreManager:
|
|||||||
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
|
||||||
f"Reinstalling from registry to migrate to new source."
|
f"Reinstalling from registry to migrate to new source."
|
||||||
)
|
)
|
||||||
if not self._safe_remove_directory(plugin_path):
|
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
||||||
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
|
|
||||||
return False
|
|
||||||
return self.install_plugin(resolved_id)
|
|
||||||
|
|
||||||
# Check if already up to date
|
# Check if already up to date
|
||||||
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
if remote_sha and local_sha and remote_sha.startswith(local_sha):
|
||||||
@@ -2632,11 +2716,11 @@ class PluginStoreManager:
|
|||||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||||
|
|
||||||
# Remove directory and reinstall fresh
|
# Reinstall with the old version kept aside until the new
|
||||||
if not self._safe_remove_directory(plugin_path):
|
# download succeeds — this is the path every routine store
|
||||||
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
# update takes, and a mid-update network failure must not
|
||||||
return False
|
# destroy the user's plugin.
|
||||||
return self.install_plugin(registry_id)
|
return self._reinstall_with_rollback(registry_id, plugin_path)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
+1
-5
@@ -38,11 +38,7 @@ def mock_cache_manager():
|
|||||||
mock._memory_cache_timestamps = {}
|
mock._memory_cache_timestamps = {}
|
||||||
mock.cache_dir = "/tmp/test_cache"
|
mock.cache_dir = "/tmp/test_cache"
|
||||||
|
|
||||||
def mock_get(key: str, max_age: Optional[int] = 300,
|
def mock_get(key: str, max_age: int = 300) -> Optional[Dict]:
|
||||||
memory_ttl: Optional[int] = None) -> Optional[Dict]:
|
|
||||||
# Signature mirrors CacheManager.get — keep in sync or callers
|
|
||||||
# passing keyword args (health tracker, resource monitor) break
|
|
||||||
# only in tests, hiding real-API compatibility.
|
|
||||||
return mock._memory_cache.get(key)
|
return mock._memory_cache.get(key)
|
||||||
|
|
||||||
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests for atomic plugin updates (store_manager._reinstall_with_rollback).
|
||||||
|
|
||||||
|
Regression for a field data-loss incident: update_plugin's reinstall paths
|
||||||
|
(monorepo migration AND routine archive updates) deleted the installed
|
||||||
|
plugin BEFORE downloading its replacement — a mid-update network failure
|
||||||
|
permanently destroyed the plugin. Seen live: a Pi with broken DNS lost 12
|
||||||
|
plugins from one update pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from src.plugin_system.store_manager import PluginStoreManager # noqa: E402
|
||||||
|
|
||||||
|
PLUGIN_ID = "rollback-test-plugin"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
mgr = PluginStoreManager(plugins_dir=str(tmp_path))
|
||||||
|
plugin_dir = tmp_path / PLUGIN_ID
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps(
|
||||||
|
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "1.0.0"}))
|
||||||
|
(plugin_dir / "manager.py").write_text("# old version marker\n")
|
||||||
|
return mgr, plugin_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestReinstallWithRollback:
|
||||||
|
def test_failed_install_restores_old_version(self, store):
|
||||||
|
"""The whole point: a failed download must leave the old install."""
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
with patch.object(mgr, "install_plugin", return_value=False):
|
||||||
|
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
||||||
|
assert ok is False
|
||||||
|
assert plugin_dir.exists()
|
||||||
|
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
||||||
|
# no aside debris left behind
|
||||||
|
leftovers = [p for p in plugin_dir.parent.iterdir()
|
||||||
|
if "standalone-backup" in p.name]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
def test_install_exception_restores_old_version(self, store):
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
with patch.object(mgr, "install_plugin",
|
||||||
|
side_effect=RuntimeError("network down")):
|
||||||
|
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
||||||
|
assert ok is False
|
||||||
|
assert plugin_dir.exists()
|
||||||
|
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
||||||
|
|
||||||
|
def test_successful_install_removes_aside(self, store):
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
|
||||||
|
def fake_install(plugin_id):
|
||||||
|
new_dir = plugin_dir # same path, new content
|
||||||
|
new_dir.mkdir(exist_ok=True)
|
||||||
|
(new_dir / "manager.py").write_text("# new version\n")
|
||||||
|
(new_dir / "manifest.json").write_text(json.dumps(
|
||||||
|
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
|
||||||
|
return True
|
||||||
|
|
||||||
|
with patch.object(mgr, "install_plugin", side_effect=fake_install):
|
||||||
|
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
||||||
|
assert ok is True
|
||||||
|
assert "new version" in (plugin_dir / "manager.py").read_text()
|
||||||
|
leftovers = [p for p in plugin_dir.parent.iterdir()
|
||||||
|
if "standalone-backup" in p.name]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
def test_partial_download_debris_is_replaced_by_old_version(self, store):
|
||||||
|
"""A failed install that left a partial directory must still roll back."""
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
|
||||||
|
def fake_partial_install(plugin_id):
|
||||||
|
plugin_dir.mkdir(exist_ok=True)
|
||||||
|
(plugin_dir / "half-downloaded.tmp").write_text("junk")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with patch.object(mgr, "install_plugin", side_effect=fake_partial_install):
|
||||||
|
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
||||||
|
assert ok is False
|
||||||
|
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
||||||
|
assert not (plugin_dir / "half-downloaded.tmp").exists()
|
||||||
|
|
||||||
|
def test_stale_aside_from_previous_crash_is_cleared(self, store):
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
stale = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
|
||||||
|
stale.mkdir()
|
||||||
|
(stale / "old.txt").write_text("stale")
|
||||||
|
with patch.object(mgr, "install_plugin", return_value=False) as mock_install:
|
||||||
|
ok = mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir)
|
||||||
|
# The reinstall itself still fails (mocked) and the old install is
|
||||||
|
# restored, but the stale aside must not have survived — otherwise
|
||||||
|
# it would have blocked this run's own rename (or a future one).
|
||||||
|
assert not stale.exists()
|
||||||
|
mock_install.assert_called_once_with(PLUGIN_ID)
|
||||||
|
assert ok is False
|
||||||
|
assert plugin_dir.exists()
|
||||||
|
assert "old version marker" in (plugin_dir / "manager.py").read_text()
|
||||||
|
|
||||||
|
def test_concurrent_updates_for_same_plugin_are_serialized(self, store):
|
||||||
|
"""Two overlapping requests for the same plugin_id (double-click,
|
||||||
|
two browser tabs — the web UI runs Flask with threaded=True) must
|
||||||
|
not interleave: the loser must wait for the winner to finish
|
||||||
|
rather than renaming the winner's in-progress install aside and
|
||||||
|
stealing its rollback safety net."""
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
|
||||||
|
active = 0
|
||||||
|
max_active = 0
|
||||||
|
guard = threading.Lock()
|
||||||
|
|
||||||
|
def fake_install(plugin_id):
|
||||||
|
nonlocal active, max_active
|
||||||
|
with guard:
|
||||||
|
active += 1
|
||||||
|
max_active = max(max_active, active)
|
||||||
|
time.sleep(0.05)
|
||||||
|
plugin_dir.mkdir(exist_ok=True)
|
||||||
|
(plugin_dir / "manager.py").write_text("# new version\n")
|
||||||
|
(plugin_dir / "manifest.json").write_text(json.dumps(
|
||||||
|
{"id": PLUGIN_ID, "name": "Rollback Test", "version": "2.0.0"}))
|
||||||
|
with guard:
|
||||||
|
active -= 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
results.append(mgr._reinstall_with_rollback(PLUGIN_ID, plugin_dir))
|
||||||
|
|
||||||
|
with patch.object(mgr, "install_plugin", side_effect=fake_install):
|
||||||
|
threads = [threading.Thread(target=worker) for _ in range(2)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
assert max_active == 1, "install_plugin ran concurrently for the same plugin_id"
|
||||||
|
assert results == [True, True]
|
||||||
|
assert plugin_dir.exists()
|
||||||
|
assert "new version" in (plugin_dir / "manager.py").read_text()
|
||||||
|
leftovers = [p for p in plugin_dir.parent.iterdir()
|
||||||
|
if "standalone-backup" in p.name]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
def test_aside_name_is_invisible_to_discovery(self, store, tmp_path):
|
||||||
|
"""The aside still contains a manifest.json — discovery must skip it
|
||||||
|
(relies on the existing '.standalone-backup-' exclusion)."""
|
||||||
|
mgr, plugin_dir = store
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
aside = plugin_dir.parent / f"{PLUGIN_ID}.standalone-backup-migrating"
|
||||||
|
plugin_dir.rename(aside)
|
||||||
|
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||||
|
display_manager=None, cache_manager=None)
|
||||||
|
found = pm._scan_directory_for_plugins(Path(tmp_path))
|
||||||
|
assert PLUGIN_ID not in found
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
Reference in New Issue
Block a user