mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
fix(plugins): fix skipped-frame health/force_change tracking, config validation, and lock-lifetime gaps in async updates
Addresses PR #407 review findings: - display_controller: only clear force_change / record health success when display() actually ran this frame, not when the frame was skipped because the plugin's lock was busy (a skip must preserve a pending mode-switch force_clear). - plugin_manager: replace bool() coercion of synchronous_updates with explicit isinstance validation of plugin_system/synchronous_updates, failing safe to synchronous mode (with a logged reason) on malformed config instead of silently defaulting to async. - plugin_manager: _update_worker_loop now acquires the plugin lock before looking up its instance and re-checks under the lock, so an unloaded plugin's lifecycle state is never resurrected to ENABLED. - plugin_manager + display_controller: move lock ownership (and, for updates, RUNNING/pending lifecycle bookkeeping) into the actual update()/ display() call itself rather than the timeout-wrapped caller, so the lock stays held for the real operation's duration even after PluginExecutor's own join(timeout) elapses and a lingering daemon thread keeps running in the background. - DisplayController.cleanup() now stops the update worker before tearing down display/cache resources; stop_update_worker() logs when the join times out instead of failing silently. - test_async_plugin_updates: rewrite test_unloaded_while_queued_is_harmless to exercise the public unload_plugin() lifecycle (via a deterministic blocker) instead of deleting pm.plugins directly, and add a regression test proving the plugin lock stays held through PluginExecutor's own timeout while the real update() call is still running. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c32aaf02e5
commit
cbc899126a
+83
-19
@@ -23,6 +23,8 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||
import time
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
@@ -1851,6 +1853,7 @@ class DisplayController:
|
||||
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
can_display = False
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
@@ -1861,30 +1864,79 @@ class DisplayController:
|
||||
)
|
||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||
|
||||
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||
if not can_display:
|
||||
# update() in flight on the worker — hold
|
||||
# the last frame; not a plugin failure
|
||||
result = True
|
||||
elif self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
# Use PluginExecutor for safe execution with timeout
|
||||
pm = self.plugin_manager
|
||||
display_lock = None
|
||||
can_display = True
|
||||
if pm and hasattr(pm, 'get_plugin_lock'):
|
||||
display_lock = pm.get_plugin_lock(plugin_id)
|
||||
can_display = display_lock.acquire(blocking=False)
|
||||
|
||||
if not can_display:
|
||||
# update() in flight on the worker — hold
|
||||
# the last frame; not a plugin failure
|
||||
result = True
|
||||
elif pm and hasattr(pm, 'plugin_executor'):
|
||||
# PluginExecutor's own thread.join(timeout) can
|
||||
# return before the real display() call
|
||||
# finishes (a lingering daemon thread keeps
|
||||
# running it) -- so the lock is released from
|
||||
# inside the wrapped call itself, whichever
|
||||
# thread actually finishes it, rather than
|
||||
# here when this dispatch merely returns.
|
||||
release_guard = threading.Lock()
|
||||
released = {'done': False}
|
||||
|
||||
def _release_display_lock():
|
||||
with release_guard:
|
||||
if released['done']:
|
||||
return
|
||||
released['done'] = True
|
||||
if display_lock is not None:
|
||||
display_lock.release()
|
||||
|
||||
if _accepts_display_mode:
|
||||
def _display_target(display_mode=None, force_clear=False):
|
||||
try:
|
||||
return manager_to_display.display(
|
||||
display_mode=display_mode, force_clear=force_clear)
|
||||
finally:
|
||||
_release_display_lock()
|
||||
else:
|
||||
def _display_target(force_clear=False):
|
||||
try:
|
||||
return manager_to_display.display(force_clear=force_clear)
|
||||
finally:
|
||||
_release_display_lock()
|
||||
|
||||
try:
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
types.SimpleNamespace(display=_display_target),
|
||||
plugin_id,
|
||||
force_clear=self.force_change,
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
)
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
result = True # Success
|
||||
else:
|
||||
result = False # Failed
|
||||
except Exception: # pragma: no cover - defensive;
|
||||
# execute_display catches everything
|
||||
# internally, but guarantee the lock is
|
||||
# never leaked if something unexpected
|
||||
# slips through.
|
||||
_release_display_lock()
|
||||
raise
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
result = True # Success
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
try:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
finally:
|
||||
if display_lock is not None:
|
||||
display_lock.release()
|
||||
|
||||
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
||||
# Check if display() returned a boolean (new behavior)
|
||||
@@ -1893,11 +1945,15 @@ class DisplayController:
|
||||
if not display_result:
|
||||
logger.info(f"Plugin {plugin_id} display() returned False for mode {active_mode}")
|
||||
|
||||
# Record success if display completed without exception
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||
|
||||
self.force_change = False
|
||||
# Record success only when display() actually ran this
|
||||
# frame -- a skipped frame (lock busy) held the last
|
||||
# frame, not a real success, and must not clear
|
||||
# force_change or the pending mode-switch clear will
|
||||
# be lost when display() finally does run.
|
||||
if can_display:
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||
self.force_change = False
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.exception("Error displaying %s", self.current_display_mode)
|
||||
# Record failure
|
||||
@@ -2754,6 +2810,14 @@ class DisplayController:
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# Stop the async update worker first so no in-flight update() call
|
||||
# is still touching display/cache-backed resources while they're
|
||||
# torn down below.
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'):
|
||||
try:
|
||||
self.plugin_manager.stop_update_worker()
|
||||
except Exception as e:
|
||||
logger.warning("Error stopping plugin update worker: %s", e)
|
||||
# Shutdown config service if it exists
|
||||
if hasattr(self, 'config_service'):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user