fix(display): thread-safety for deferred updates, BDF faces and follower image; one refresh default (#652)

- DisplayManager.defer_update()/process_deferred_updates(): one lock around
  every queue mutation (appends from the update thread were lost to the
  render thread's filter/slice reassignments); callables run outside it.
- FontManager and element_style no longer cache BDF freetype.Face objects
  process-wide (load_bdf_face caches them per thread); element_style's LRU
  is locked against get/move_to_end vs eviction races.
- limit_refresh_rate_hz default is one constant, DEFAULT_REFRESH_LIMIT_HZ =
  100 (the template's), for the library options, refresh_hz, the matrix
  guard, Vegas and scroll_config. Previously a missing key capped the panel
  at 90 while pacing assumed 100.
- Sync follower: the TCP thread queues the leader's scroll image; the render
  thread swaps image/array/width in between frames.
- update_display() error log rate-limited (traceback first, then once a
  minute with a count); swallowed DisplayController exceptions log at DEBUG.
- Root display_controller.py runs run.py via runpy.
- stream_manager: correct the RLock release comments; merge duplicate if.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 10:39:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 1e62677257
commit 6f45ff5e63
17 changed files with 590 additions and 79 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ from PIL import Image
from src.common.scroll_config import solve_crisp
from src.common.scroll_helper import ScrollHelper
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager
@@ -199,7 +200,7 @@ class RenderPipeline:
hz = float(getattr(self.display_manager, 'refresh_hz', 0) or 0)
except (TypeError, ValueError):
hz = 0.0
return hz if hz > 0 else 100.0
return hz if hz > 0 else float(DEFAULT_REFRESH_LIMIT_HZ)
def _refresh_hz(self) -> float:
"""The refresh to solve the crisp speed against: measured, else the cap."""
+9 -4
View File
@@ -74,8 +74,11 @@ class StreamManager:
# Segments composed into the current cycle (swap mode only).
self._active_buffer: Deque[ContentSegment] = deque()
# Reentrant: _prefetch_content releases and re-acquires it around the
# slow fetch while a caller may already hold it.
# Reentrant: get_next_segment holds it while calling
# _prefetch_content, which acquires it again. _prefetch_content's
# release() around the slow fetch only frees the lock when its caller
# did not already hold it (initialize); from get_next_segment the
# count only drops to 1, so the fetch runs with the lock held.
self._buffer_lock = threading.RLock()
# Plugin rotation, and the position of the next plugin to fetch in it.
@@ -536,7 +539,10 @@ class StreamManager:
plugin_id = self._ordered_plugins[self._prefetch_index]
# Release lock for potentially slow content fetch
# Release for the potentially slow content fetch. This frees
# the lock only when the caller did not hold it already
# (initialize); under get_next_segment's hold the RLock count
# just drops to 1 and other threads still wait.
self._buffer_lock.release()
try:
segment = self._fetch_plugin_content(plugin_id)
@@ -765,7 +771,6 @@ class StreamManager:
continue
if images:
self.stats['segments_fetched'] += 1
if images:
group.append((plugin_id, images))
else:
group.append((plugin_id, None if defer_empty else []))