mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3869e34911 | ||
|
|
62fbed3471 |
@@ -2125,23 +2125,10 @@ class DisplayController:
|
|||||||
|
|
||||||
# For plugins, call display multiple times to allow game rotation
|
# For plugins, call display multiple times to allow game rotation
|
||||||
if manager_to_display and hasattr(manager_to_display, 'display'):
|
if manager_to_display and hasattr(manager_to_display, 'display'):
|
||||||
# High-FPS decision, in precedence order:
|
# Check if plugin needs high FPS (like stock ticker)
|
||||||
# 1. A plugin that declares needs_high_fps knows best
|
# Always enable high-FPS for static-image plugin (for GIF animation support)
|
||||||
# (e.g. static-image sets it False for still PNGs,
|
|
||||||
# True for animated GIFs).
|
|
||||||
# 2. Back-compat: older static-image versions without
|
|
||||||
# the attribute keep the historical forced high-FPS
|
|
||||||
# (GIF support).
|
|
||||||
# 3. Otherwise scrolling plugins get high FPS.
|
|
||||||
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
||||||
declared = getattr(manager_to_display, 'needs_high_fps', None)
|
if plugin_id == 'static-image':
|
||||||
if declared is not None:
|
|
||||||
needs_high_fps = bool(declared)
|
|
||||||
logger.debug(
|
|
||||||
"[DisplayController] FPS check for %s (plugin=%s) - "
|
|
||||||
"plugin declares needs_high_fps=%s",
|
|
||||||
active_mode, plugin_id, needs_high_fps)
|
|
||||||
elif plugin_id == 'static-image':
|
|
||||||
needs_high_fps = True
|
needs_high_fps = True
|
||||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+25
-81
@@ -33,7 +33,6 @@ else:
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
@@ -220,17 +219,6 @@ class DisplayManager:
|
|||||||
# but is never silent: the snapshot's mtime doubles as the web UI's
|
# 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.
|
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
||||||
self._snapshot_fail_log_ts = 0.0
|
self._snapshot_fail_log_ts = 0.0
|
||||||
# Dirty tracking: (image digest, brightness) of the last frame pushed
|
|
||||||
# to the panel; update_display() skips identical pushes. Kill switch:
|
|
||||||
# display.dirty_tracking: false.
|
|
||||||
self._dirty_tracking_enabled = bool(
|
|
||||||
self.config.get('display', {}).get('dirty_tracking', True))
|
|
||||||
self._last_pushed_digest = None
|
|
||||||
# Serializes update_display(): plugins can call it directly from
|
|
||||||
# background threads (see docstring on update_display), not just the
|
|
||||||
# render loop. RLock in case a caller within the critical section
|
|
||||||
# ever re-enters (e.g. via a nested draw callback).
|
|
||||||
self._update_lock = threading.RLock()
|
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -461,10 +449,6 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
# RGBMatrix accepts brightness as a property
|
# RGBMatrix accepts brightness as a property
|
||||||
self.matrix.brightness = brightness
|
self.matrix.brightness = brightness
|
||||||
# Brightness applies on the next swap — force a re-push even if
|
|
||||||
# the image itself is unchanged (belt-and-braces: brightness is
|
|
||||||
# also part of the dirty-tracking digest when readable).
|
|
||||||
self._last_pushed_digest = None
|
|
||||||
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
||||||
return True
|
return True
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
@@ -556,70 +540,33 @@ class DisplayManager:
|
|||||||
return phys
|
return phys
|
||||||
|
|
||||||
def update_display(self):
|
def update_display(self):
|
||||||
"""Update the display using double buffering with proper sync.
|
"""Update the display using double buffering with proper sync."""
|
||||||
|
|
||||||
Skips the panel push entirely when the frame is byte-identical to
|
|
||||||
the last pushed one (same image digest AND same brightness) — static
|
|
||||||
content re-rendered every second, and 125 fps loops between actual
|
|
||||||
scroll steps, otherwise re-walk the full framebuffer for nothing.
|
|
||||||
The panel keeps refreshing the current frame from its own thread,
|
|
||||||
so skipping a swap never blanks or freezes the hardware.
|
|
||||||
|
|
||||||
Correctness hinges on invalidation: clear() resets the digest (it
|
|
||||||
writes to the matrix directly), and brightness is PART of the digest
|
|
||||||
so a dim-schedule change is never skipped. Disable via config
|
|
||||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
|
||||||
|
|
||||||
Serialized via ``_update_lock``: plugins can call this directly from
|
|
||||||
background threads (e.g. sports base classes push an immediate
|
|
||||||
"live" refresh from inside update()), so without a lock two callers
|
|
||||||
could both pass the digest check before either writes it back,
|
|
||||||
double-pushing a frame, or interleave the offscreen/current canvas
|
|
||||||
swap below. The lock is scoped to this method, so callers never
|
|
||||||
need to know about it.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
with self._update_lock:
|
if self.matrix is None:
|
||||||
if self.matrix is None:
|
# Fallback mode - no actual hardware to update
|
||||||
# Fallback mode - no actual hardware to update
|
logger.debug("Update display called in fallback mode (no hardware)")
|
||||||
logger.debug("Update display called in fallback mode (no hardware)")
|
# Still write a snapshot so the web UI can preview
|
||||||
# Still write a snapshot so the web UI can preview
|
|
||||||
self._write_snapshot_if_due()
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._capture_mode_active:
|
|
||||||
return # Skip hardware write — content is being captured off-screen
|
|
||||||
|
|
||||||
digest = None
|
|
||||||
if self._dirty_tracking_enabled:
|
|
||||||
try:
|
|
||||||
brightness = getattr(self.matrix, 'brightness', None)
|
|
||||||
except AttributeError:
|
|
||||||
brightness = None
|
|
||||||
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
|
||||||
if digest == self._last_pushed_digest:
|
|
||||||
# Nothing changed since the last push — the panel is
|
|
||||||
# already showing exactly this frame.
|
|
||||||
self._write_snapshot_if_due()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas. In double-sided
|
|
||||||
# mode the logical screen is first tiled across the full chain.
|
|
||||||
if self._double_sided is not None:
|
|
||||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
|
||||||
else:
|
|
||||||
self.offscreen_canvas.SetImage(self.image)
|
|
||||||
|
|
||||||
# Swap buffers immediately
|
|
||||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
|
||||||
|
|
||||||
# Swap our canvas references
|
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
|
||||||
|
|
||||||
self._last_pushed_digest = digest
|
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
|
||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._capture_mode_active:
|
||||||
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
|
# Copy the current image to the offscreen canvas. In double-sided
|
||||||
|
# mode the logical screen is first tiled across the full chain.
|
||||||
|
if self._double_sided is not None:
|
||||||
|
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||||
|
else:
|
||||||
|
self.offscreen_canvas.SetImage(self.image)
|
||||||
|
|
||||||
|
# Swap buffers immediately
|
||||||
|
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||||
|
|
||||||
|
# Swap our canvas references
|
||||||
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
|
|
||||||
|
# Write a snapshot for the web preview (throttled)
|
||||||
|
self._write_snapshot_if_due()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
@@ -653,9 +600,6 @@ class DisplayManager:
|
|||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
# Failures are non-fatal — the image buffer is already black above, so
|
# Failures are non-fatal — the image buffer is already black above, so
|
||||||
# the next update_display() call will push clean content regardless.
|
# the next update_display() call will push clean content regardless.
|
||||||
# The matrix content no longer matches the last pushed digest,
|
|
||||||
# so dirty tracking must not skip the next push.
|
|
||||||
self._last_pushed_digest = None
|
|
||||||
try:
|
try:
|
||||||
self.offscreen_canvas.Clear()
|
self.offscreen_canvas.Clear()
|
||||||
except (RuntimeError, OSError) as e:
|
except (RuntimeError, OSError) as e:
|
||||||
|
|||||||
@@ -142,28 +142,9 @@ 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
|
||||||
@@ -2282,74 +2263,6 @@ 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.
|
||||||
@@ -2412,7 +2325,10 @@ 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."
|
||||||
)
|
)
|
||||||
return self._reinstall_with_rollback(resolved_id, plugin_path)
|
if not self._safe_remove_directory(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):
|
||||||
@@ -2716,11 +2632,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})")
|
||||||
|
|
||||||
# Reinstall with the old version kept aside until the new
|
# Remove directory and reinstall fresh
|
||||||
# download succeeds — this is the path every routine store
|
if not self._safe_remove_directory(plugin_path):
|
||||||
# update takes, and a mid-update network failure must not
|
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||||
# destroy the user's plugin.
|
return False
|
||||||
return self._reinstall_with_rollback(registry_id, plugin_path)
|
return self.install_plugin(registry_id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
"""Tests for update_display dirty tracking (src/display_manager.py).
|
|
||||||
|
|
||||||
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
|
|
||||||
DisplayManager — not a mock — so the skip logic, its invalidation hooks,
|
|
||||||
and the kill switch are verified off-Pi.
|
|
||||||
|
|
||||||
The invariants:
|
|
||||||
- identical frames are pushed exactly once (SwapOnVSync not re-called)
|
|
||||||
- ANY pixel change pushes
|
|
||||||
- clear() and set_brightness() invalidate (the two paths that alter panel
|
|
||||||
state outside the digest's view)
|
|
||||||
- the kill switch (display.dirty_tracking: false) restores always-push
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
os.environ["EMULATOR"] = "true"
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def dm():
|
|
||||||
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
DisplayManager._instance = None
|
|
||||||
DisplayManager._initialized = False
|
|
||||||
manager = DisplayManager({
|
|
||||||
"display": {
|
|
||||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
|
||||||
"parallel": 1, "brightness": 90},
|
|
||||||
"runtime": {"gpio_slowdown": 0},
|
|
||||||
},
|
|
||||||
}, suppress_test_pattern=True)
|
|
||||||
yield manager
|
|
||||||
DisplayManager._instance = None
|
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
class _SwapSpy:
|
|
||||||
"""Counts SwapOnVSync calls through the real matrix object."""
|
|
||||||
|
|
||||||
def __init__(self, matrix):
|
|
||||||
self.matrix = matrix
|
|
||||||
self.count = 0
|
|
||||||
self._orig = matrix.SwapOnVSync
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
def counting(canvas):
|
|
||||||
self.count += 1
|
|
||||||
return self._orig(canvas)
|
|
||||||
self.matrix.SwapOnVSync = counting
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc):
|
|
||||||
self.matrix.SwapOnVSync = self._orig
|
|
||||||
|
|
||||||
|
|
||||||
class TestDirtyTracking:
|
|
||||||
def test_identical_frames_push_once(self, dm):
|
|
||||||
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 1
|
|
||||||
|
|
||||||
def test_pixel_change_pushes(self, dm):
|
|
||||||
dm.update_display()
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.draw.point((5, 5), fill=(0, 255, 0))
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display() # unchanged again
|
|
||||||
assert spy.count == 1
|
|
||||||
|
|
||||||
def test_clear_invalidates(self, dm):
|
|
||||||
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
|
|
||||||
dm.update_display()
|
|
||||||
dm.clear() # writes to the matrix directly; digest must reset
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display() # black frame after clear must still push
|
|
||||||
assert spy.count == 1
|
|
||||||
|
|
||||||
def test_brightness_change_forces_push(self, dm):
|
|
||||||
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
|
|
||||||
dm.update_display()
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display() # identical -> skipped
|
|
||||||
assert spy.count == 0
|
|
||||||
dm.set_brightness(40) # dim schedule scenario
|
|
||||||
dm.update_display() # same image, new brightness -> push
|
|
||||||
assert spy.count == 1
|
|
||||||
dm.set_brightness(90)
|
|
||||||
|
|
||||||
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
|
|
||||||
"""The web preview mirror must keep working through skipped panel
|
|
||||||
pushes: _write_snapshot_if_due() still runs on the dirty-tracking
|
|
||||||
skip path and applies its own write/touch policy rather than being
|
|
||||||
bypassed entirely (see src/common/snapshot_policy.py — an unchanged
|
|
||||||
frame is touched, not re-encoded, once TOUCH_INTERVAL elapses)."""
|
|
||||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
|
||||||
dm._last_snapshot_ts = 0.0
|
|
||||||
dm._last_snapshot_touch_ts = 0.0
|
|
||||||
dm._last_snapshot_digest = None
|
|
||||||
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
|
||||||
dm.update_display() # push + snapshot write (first frame)
|
|
||||||
assert os.path.exists(dm._snapshot_path)
|
|
||||||
first_mtime = os.path.getmtime(dm._snapshot_path)
|
|
||||||
|
|
||||||
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
|
||||||
# identical frame is due for a touch, then push it again: dirty
|
|
||||||
# tracking must skip the panel write, but the snapshot mirror must
|
|
||||||
# still get its mtime bumped so the health check doesn't go stale.
|
|
||||||
from src.common import snapshot_policy
|
|
||||||
stale_ts = time.time() - snapshot_policy.TOUCH_INTERVAL - 1.0
|
|
||||||
dm._last_snapshot_ts = stale_ts
|
|
||||||
dm._last_snapshot_touch_ts = stale_ts
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display() # identical frame -> panel push skipped
|
|
||||||
assert spy.count == 0
|
|
||||||
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
|
||||||
|
|
||||||
|
|
||||||
class TestKillSwitch:
|
|
||||||
def test_dirty_tracking_can_be_disabled(self, dm):
|
|
||||||
dm._dirty_tracking_enabled = False
|
|
||||||
try:
|
|
||||||
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
|
|
||||||
with _SwapSpy(dm.matrix) as spy:
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
dm.update_display()
|
|
||||||
assert spy.count == 3 # always-push, exactly the old behavior
|
|
||||||
finally:
|
|
||||||
dm._dirty_tracking_enabled = True
|
|
||||||
dm._last_pushed_digest = None
|
|
||||||
|
|
||||||
def test_config_flag_wires_through(self):
|
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
DisplayManager._instance = None
|
|
||||||
DisplayManager._initialized = False
|
|
||||||
try:
|
|
||||||
manager = DisplayManager({
|
|
||||||
"display": {
|
|
||||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
|
|
||||||
"parallel": 1},
|
|
||||||
"runtime": {"gpio_slowdown": 0},
|
|
||||||
"dirty_tracking": False,
|
|
||||||
},
|
|
||||||
}, suppress_test_pattern=True)
|
|
||||||
assert manager._dirty_tracking_enabled is False
|
|
||||||
finally:
|
|
||||||
DisplayManager._instance = None
|
|
||||||
DisplayManager._initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(pytest.main([__file__, "-v"]))
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
"""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