Files
LEDMatrix/test/test_deferred_updates_threads.py
T
ChuckandClaude Opus 5.5 6f45ff5e63 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>
2026-09-28 10:39:40 -04:00

112 lines
4.2 KiB
Python

"""DisplayManager's deferred-update queue across threads.
defer_update() is called from plugin update() on the update worker thread
while process_deferred_updates() runs on the render thread. Both rebuild the
queue list (TTL filter, [n:] slice) and assign it back, so an append that
landed between one side's read and its assignment used to be dropped.
The race is forced deterministically: _scrolling_state is swapped for a dict
whose first store of 'deferred_updates' (made by the render side) first lets
a worker-thread defer_update() run. Unlocked, the worker appends to the list
the render side is about to overwrite; locked, the worker waits and appends
to the list that was stored.
"""
import os
import sys
import threading
os.environ["EMULATOR"] = "true"
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(scope="module")
def dm():
from src.display_manager import DisplayManager
DisplayManager._instance = None
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
"parallel": 1, "brightness": 90},
"runtime": {"gpio_slowdown": 0},
},
}, suppress_test_pattern=True)
yield manager
DisplayManager._instance = None
class _InterleavingState(dict):
"""On the first 'deferred_updates' store from the owning thread, run
``interleave`` on another thread before the store happens."""
def __init__(self, base, interleave):
super().__init__(base)
dict.__setitem__(self, 'deferred_updates', [])
self._owner = threading.get_ident()
self._interleave = interleave
self.worker = None
def __setitem__(self, key, value):
if (key == 'deferred_updates' and self.worker is None
and threading.get_ident() == self._owner):
self.worker = threading.Thread(target=self._interleave, daemon=True)
self.worker.start()
# Unlocked code lets the worker finish here; locked code blocks
# it on the lock this thread holds, so give up waiting quickly.
self.worker.join(timeout=0.5)
super().__setitem__(key, value)
def test_defer_from_another_thread_is_not_lost(dm):
original = dm._scrolling_state
ran = [] # the callables never run here; they only need to exist
try:
state = _InterleavingState(
original, lambda: dm.defer_update(lambda: ran.append('late')))
dm._scrolling_state = state
dm.defer_update(lambda: ran.append('first'))
state.worker.join(timeout=5)
assert not state.worker.is_alive()
assert len(state['deferred_updates']) == 2, (
"a defer_update() from another thread was lost")
finally:
dm._scrolling_state = original
original['deferred_updates'] = []
def test_process_during_defer_keeps_both(dm):
original = dm._scrolling_state
try:
state = _InterleavingState(
original, lambda: dm.defer_update(lambda: None))
dict.__setitem__(state, 'is_scrolling', True)
dict.__setitem__(state, 'last_scroll_activity', 1e18) # stays "scrolling"
dm._scrolling_state = state
# Render side: only the TTL cleanup runs while scrolling.
dm.process_deferred_updates()
state.worker.join(timeout=5)
assert not state.worker.is_alive()
assert len(state['deferred_updates']) == 1, (
"a defer_update() during the render-side cleanup was lost")
finally:
dm._scrolling_state = original
original['deferred_updates'] = []
def test_queued_callable_may_defer_again(dm):
"""The callables run outside the lock, so one that re-defers (a plugin
retrying later) must not deadlock the render thread."""
dm._scrolling_state['is_scrolling'] = False
try:
dm.defer_update(lambda: dm.defer_update(lambda: None))
t = threading.Thread(target=dm.process_deferred_updates, daemon=True)
t.start()
t.join(timeout=5)
assert not t.is_alive(), "process_deferred_updates deadlocked"
assert len(dm._scrolling_state['deferred_updates']) == 1
finally:
dm._scrolling_state['deferred_updates'] = []