Files
LEDMatrix/test/test_plugin_reload_off_render_thread.py
ChuckandClaude Opus 5.5 8136a2d525 fix(ipc): a plugin reload no longer freezes the panel during Vegas (#723)
A plugin.reload no longer freezes the panel during Vegas: the old instance is torn down and the new one loaded off the render thread (frame gap 3017 ms -> 9 ms in the ledpi reproduction). A failed or timed-out teardown stops the reload with a restart hint instead of loading over stale modules or tearing down an instance still in use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:42:23 -04:00

528 lines
21 KiB
Python

"""A ``plugin.reload`` never stalls the render thread (#720 follow-up).
On ledpi a reload of football-scoreboard (a store update, 3.17.0 to 3.18.1)
arrived while Vegas mode was running, and the panel froze for 3.0 s
("Render stall over: no frame for 3043ms"). Vegas yielded at once, but the
reload ran on the render thread, and its first step, unload_plugin(), waited
for the plugin's lock. The strip's prefetch thread held that lock: it was
rebuilding the old instance's Vegas content, which takes seconds for a
scoreboard. The new instance was then loaded on the render thread too
(0.47 s on ledpi).
Now the render thread only takes the plugin out of the rotation and out of
the plugin manager (detach_plugin), and a plugin-reload thread waits for the
lock, tears the old instance down and loads the new one. The new instance
joins the rotation between two frames.
* Real threads, real PluginManager and PluginAdapter: frames keep coming
(no gap over 100 ms) while a slow Vegas render holds the old instance's
lock, and the reload still reports the real outcome. Before the fix the
gap was the whole render.
* The fake-clock run loop (test/_run_loop_harness.py): Vegas frames flow
through a reload whose thread takes 3 s, the ticker never leaves, and
the reload is answered when the new instance joins.
* Thread safety: the old instance is torn down only after its render let
go of the lock, is never asked for content again, and never gets an
update() once the reload starts; the new one is loaded once.
* The other reload cases still answer as before: a plugin that is on
screen, one that is not, one that fails to load, and a second reload of
a plugin that is still loading.
"""
import json
import os
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from PIL import Image
os.environ.setdefault("EMULATOR", "true")
from src.ipc.contract import Command, ErrorCode, PluginReloadArgs # noqa: E402
from src.ipc.server import CommandOutcome, QueuedCommand # noqa: E402
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
from test.test_vegas_elements_api import _DM # noqa: E402
#: How long the old instance's Vegas render holds its lock. ledpi's was 3 s
#: (scripts in the PR body measure that); the gap scales with it.
RENDER_SECONDS = 1.5
FRAME = 0.008
#: "A frame or two", with room for a slow CI runner.
MAX_GAP = 0.1
def _reload(plugin_id, request_id="r1"):
return QueuedCommand(request_id=request_id, cmd=Command.PLUGIN_RELOAD,
args=PluginReloadArgs(plugin_id), received_at=time.time(),
outcome=CommandOutcome())
class _Server:
"""The ControlServer surface the controller drains."""
def __init__(self):
self.commands = []
@property
def has_pending(self):
return bool(self.commands)
def drain(self):
out, self.commands = self.commands, []
return out
def close(self):
pass
class _Rig:
"""A real PluginManager running one slow-rendering plugin, 'slow'."""
def __init__(self, tmp_path, render_seconds):
self.events = []
self.lock = threading.Lock()
self.render_started = threading.Event()
self.render_seconds = render_seconds
self.generation = 0
rig = self
class SlowVegas(BasePlugin):
"""Draws its Vegas content slowly, as a scoreboard does on a Pi."""
def __init__(self, generation): # pylint: disable=super-init-not-called
self.plugin_id = "slow"
self.config = {"enabled": True}
self.enabled = True
self.modes = ["slow"]
self.plugin_manager = None
self.generation = generation
def get_update_interval(self):
return 0.05
def update(self):
rig.log("update", self.generation)
def display(self, force_clear=False):
pass
def validate_config(self):
return True
def get_vegas_content(self):
rig.log("render-start", self.generation)
rig.render_started.set()
time.sleep(rig.render_seconds if self.generation == 1 else 0)
rig.log("render-end", self.generation)
return [Image.new("RGB", (64, 16), (255, 0, 0))]
def cleanup(self):
rig.log("cleanup", self.generation)
def on_enable(self):
pass
def on_disable(self):
pass
def construct(**_kw):
rig.generation += 1
return SlowVegas(rig.generation), None
plugin_dir = tmp_path / "plugins" / "slow"
plugin_dir.mkdir(parents=True)
self.manifest_path = plugin_dir / "manifest.json"
manifest = {"id": "slow", "version": "1.0.0", "display_modes": ["slow"]}
self.manifest_path.write_text(json.dumps(manifest))
pm = PluginManager(plugins_dir=str(tmp_path / "plugins"))
pm.plugin_manifests["slow"] = manifest
pm.schema_manager = MagicMock()
pm.schema_manager.get_schema_path.return_value = None
pm.schema_manager.prepare_plugin_config.side_effect = (
lambda pid, cfg, schema=None, changed_paths=None: cfg)
pm.plugin_loader = MagicMock()
pm.plugin_loader.find_plugin_directory.return_value = plugin_dir
pm.plugin_loader.load_plugin.side_effect = construct
assert pm.load_plugin("slow")
self.pm = pm
self.adapter = PluginAdapter(_DM(), VegasModeConfig(), plugin_manager=pm)
self.prefetched = []
def log(self, kind, generation):
with self.lock:
self.events.append((kind, generation))
def index(self, kind, generation):
return self.events.index((kind, generation))
def start_vegas_render(self):
"""The strip's prefetch thread rebuilding the old instance's content."""
plugin = self.pm.plugins["slow"]
def fetch():
self.prefetched.append(self.adapter.get_content(plugin, "slow", offscreen_only=True))
thread = threading.Thread(target=fetch, name="vegas-strip-prefetch", daemon=True)
thread.start()
assert self.render_started.wait(2)
return thread
@pytest.fixture
def dc(test_display_controller):
c = test_display_controller
c.display_manager.set_brightness = MagicMock(return_value=True)
c.display_manager.update_display = MagicMock()
c.config = {"timezone": "UTC", "display": {"hardware": {"brightness": 90}}}
c._normal_brightness = 90
c.current_brightness = 90
c.is_display_active = True
c._tz = None
c.vegas_coordinator = MagicMock()
return c
def _with_rig(dc, tmp_path, render_seconds=None):
rig = _Rig(tmp_path, RENDER_SECONDS if render_seconds is None else render_seconds)
clock = SimpleNamespace(modes=["clock"])
dc.plugin_manager = rig.pm
dc.plugin_display_modes = {"clock": ["clock"]}
dc.available_modes = ["clock"]
dc.plugin_modes = {"clock": clock}
dc.mode_to_plugin_id = {"clock": "clock"}
dc._register_loaded_plugin("slow")
dc.current_mode_index = 0
dc.current_display_mode = "clock"
dc._control_server = _Server()
return rig
def _render_frames(dc, rig, until, limit):
"""What the render thread does while Vegas runs: one frame every 8 ms,
servicing pending changes between frames (Vegas's interrupt check) and,
when a screen-ending command is pending, the top of the loop pass.
Returns the frame times."""
frames = [time.perf_counter()]
deadline = frames[0] + limit
while not until():
dc._service_pending_changes()
if dc._plugin_reload_pending:
dc._apply_pending_plugin_reloads() # Vegas yields; resumes next pass
if len(frames) % 5 == 0:
rig.pm.run_scheduled_updates() # the update tick
time.sleep(FRAME)
frames.append(time.perf_counter())
assert frames[-1] < deadline, "the reload never finished"
return frames
def _max_gap(frames):
return max(b - a for a, b in zip(frames, frames[1:]))
class TestFramesKeepFlowing:
def test_a_reload_during_a_slow_vegas_render(self, dc, tmp_path):
rig = _with_rig(dc, tmp_path)
old = rig.pm.plugins["slow"]
prefetch = rig.start_vegas_render()
rig.manifest_path.write_text(json.dumps(
{"id": "slow", "version": "2.0.0", "display_modes": ["slow"]}))
command = _reload("slow")
posted = time.perf_counter()
dc._control_server.commands.append(command)
frames = _render_frames(dc, rig, lambda: command.outcome.done, limit=10)
answered = time.perf_counter() - posted
prefetch.join(2)
rig.pm.stop_update_worker()
gap = _max_gap(frames)
print(f"\nlongest frame gap {gap * 1000:.0f} ms, answered after {answered:.2f} s "
f"(the old instance's render held its lock {RENDER_SECONDS:.1f} s)")
assert gap < MAX_GAP
# The real outcome, once the new instance is in the rotation.
assert command.outcome.result == {"plugin_id": "slow", "reloaded": True,
"version": "2.0.0", "modes": ["slow"]}
new = rig.pm.plugins["slow"]
assert new is not old and new.generation == 2
assert dc.plugin_modes["slow"] is new
assert dc.available_modes == ["clock", "slow"]
assert dc.current_display_mode == "clock"
dc.vegas_coordinator.mark_plugin_updated.assert_called_once_with("slow")
# The old instance finished its render before it was torn down, and
# the strip got that render.
assert rig.index("render-end", 1) < rig.index("cleanup", 1)
assert rig.prefetched and rig.prefetched[0]
# Loaded once; no update() of the old instance once it was torn
# down, and none of the new one before.
assert rig.generation == 2
cleanup = rig.index("cleanup", 1)
assert all(i < cleanup for i, e in enumerate(rig.events) if e == ("update", 1))
assert all(i > cleanup for i, e in enumerate(rig.events) if e == ("update", 2))
def test_a_fetch_that_waited_out_the_reload_skips_the_old_instance(self, tmp_path):
"""A Vegas fetch that looked the old instance up before the reload,
and got its lock only after the teardown, does not run it."""
rig = _Rig(tmp_path, 0)
old = rig.pm.detach_plugin("slow")
assert rig.adapter.get_content(old, "slow", offscreen_only=True) is None
rig.pm.unload_detached_plugin("slow", old)
assert rig.pm.reload_plugin("slow")
assert rig.adapter.get_content(old, "slow", offscreen_only=True) is None
assert ("render-start", 1) not in rig.events
# The new instance is fetched as usual.
assert rig.adapter.get_content(rig.pm.plugins["slow"], "slow", offscreen_only=True)
assert ("render-start", 2) in rig.events
class TestPluginManagerDetach:
def test_teardown_waits_for_the_lock_and_leaves_plugins_alone(self, tmp_path):
rig = _Rig(tmp_path, 0)
pm = rig.pm
old = pm.detach_plugin("slow")
assert "slow" not in pm.plugins and old.generation == 1
lock = pm.get_plugin_lock("slow")
lock.acquire()
done = threading.Event()
threading.Thread(target=lambda: (pm.unload_detached_plugin("slow", old), done.set()),
daemon=True).start()
assert not done.wait(0.2) # a render of the old instance holds it
assert ("cleanup", 1) not in rig.events
lock.release()
assert done.wait(5)
assert ("cleanup", 1) in rig.events
assert pm.reload_plugin("slow")
assert pm.plugins["slow"].generation == 2
def test_teardown_is_refused_while_the_lock_stays_held(self, tmp_path):
"""A Vegas call that took the lock before the detach may still be
running in the old instance: never tear it down from under it."""
rig = _Rig(tmp_path, 0)
pm = rig.pm
pm.DETACHED_UNLOAD_LOCK_TIMEOUT = 0.2
old = pm.detach_plugin("slow")
lock = pm.get_plugin_lock("slow")
lock.acquire()
try:
assert pm.unload_detached_plugin("slow", old) is False
assert ("cleanup", 1) not in rig.events
finally:
lock.release()
def test_detached_teardown_outwaits_the_ordinary_unload_bound(self):
# A Vegas build of the old instance held the lock 5.9 s on ledpi.
from src.plugin_system.plugin_manager import PluginManager
assert PluginManager.DETACHED_UNLOAD_LOCK_TIMEOUT > 2 * PluginManager.UNLOAD_LOCK_TIMEOUT
def test_detaching_a_plugin_that_is_not_loaded(self, tmp_path):
pm = _Rig(tmp_path, 0).pm
assert pm.detach_plugin("nope") is None
# -- the run loop on the fake clock ----------------------------------------
POSTED = 10.3
def _with_reloadable(h, version="2.0.0", loads=True):
new = FakePlugin("clock", ["clock"], duration=30)
def reload_plugin(plugin_id):
h.log("reload", plugin_id)
if not loads:
return False
new._h = h
h.pm.plugins[plugin_id] = new
h.pm.plugin_manifests[plugin_id] = {"version": version, "display_modes": ["clock"]}
return True
h.pm.reload_plugin = reload_plugin
return new
def _vegas(h):
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
h.enable_vegas(cycle=30)
def _hold_lock(h, plugin_id, seconds):
"""A Vegas render of ``plugin_id`` holds its lock for ``seconds`` from
when the reload is asked for: the render thread would block that long in
unload_plugin(), and the plugin-reload thread does instead."""
real_unload = h.pm.unload_plugin
def unload_plugin(pid):
if pid == plugin_id:
h.clock.sleep(max(0.0, POSTED + seconds - h.clock.rel()))
return real_unload(pid)
h.pm.unload_plugin = unload_plugin
h.reload_seconds = seconds
def _vegas_frame_gaps(h):
times = [e[0] for e in h.events if e[1] == "vegas-frame"]
return max(b - a for a, b in zip(times, times[1:]))
def test_vegas_keeps_scrolling_through_a_slow_reload(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_vegas(h)
new = _with_reloadable(h)
_hold_lock(h, "clock", 3.0)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
trace = h.run()
# Frames every 8 ms throughout: before the fix, none for 3 s.
assert _vegas_frame_gaps(h) <= 0.1
# The new instance loaded 3 s later, on its own thread, and joined then.
reload_at = next(e[0] for e in h.events if e[1] == "reload")
assert reload_at == pytest.approx(POSTED + 3.0, abs=0.01)
assert command.outcome.result == {"plugin_id": "clock", "reloaded": True,
"version": "2.0.0", "modes": ["clock"]}
assert h.controller.plugin_modes["clock"] is new
assert h.controller.available_modes == ["clock", "weather"]
# Only the start of the reload ended a Vegas iteration; the ticker
# carried on through it.
assert all(r[1] == "<vegas>" for r in trace["screens"] if r[0] >= POSTED)
def test_a_reload_while_on_screen_lets_the_rotation_carry_on(tmp_path):
h = RunLoopHarness(tmp_path, horizon=70)
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
new = _with_reloadable(h)
_hold_lock(h, "clock", 3.0)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
trace = h.run()
# The clock screen ends when the reload is asked for, weather shows
# while the clock loads, and the reloaded clock comes round next.
assert trace["screens"][0][:3] == [0.0, "clock", POSTED]
assert trace["screens"][1][:2] == [POSTED, "weather"]
assert trace["screens"][2][1] == "clock"
assert command.outcome.result["reloaded"] is True
assert h.controller.plugin_modes["clock"] is new
def test_a_reload_of_a_plugin_not_on_screen_keeps_its_place(tmp_path):
h = RunLoopHarness(tmp_path, horizon=70)
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
h.add_plugin(FakePlugin("news", ["news"], duration=30))
new_weather = FakePlugin("weather", ["weather"], duration=30)
def reload_plugin(plugin_id):
h.log("reload", plugin_id)
new_weather._h = h
h.pm.plugins[plugin_id] = new_weather
h.pm.plugin_manifests[plugin_id] = {"version": "2.0.0"}
return True
h.pm.reload_plugin = reload_plugin
h.reload_seconds = 3.0
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "weather"})
h.run()
assert command.outcome.result["reloaded"] is True
assert h.controller.available_modes == ["clock", "weather", "news"]
assert h.controller.plugin_modes["weather"] is new_weather
def test_a_plugin_that_fails_to_load_after_a_slow_teardown(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_vegas(h)
_with_reloadable(h, loads=False)
_hold_lock(h, "clock", 3.0)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
h.run()
assert _vegas_frame_gaps(h) <= 0.1
assert command.outcome.error_code == ErrorCode.FAILED
assert "clock" not in h.controller.available_modes
assert "clock" not in h.pm.plugins
def test_a_failed_teardown_stops_the_reload(tmp_path):
"""Loading over a half-unloaded plugin could reuse its old module and
report a reload that never happened; report the failure instead."""
h = RunLoopHarness(tmp_path, horizon=40)
_vegas(h)
reloads = []
h.pm.unload_detached_plugin = lambda plugin_id, instance: False
h.pm.reload_plugin = lambda plugin_id: reloads.append(plugin_id) or True
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
h.run()
assert reloads == []
assert command.outcome.error_code == ErrorCode.FAILED
assert "restart the display" in command.outcome.error_message
assert "clock" not in h.controller.available_modes
assert _vegas_frame_gaps(h) <= 0.1
def test_a_second_reload_while_loading_runs_after_the_first(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_vegas(h)
loads = []
def reload_plugin(plugin_id):
plugin = FakePlugin("clock", ["clock"], duration=30)
plugin._h = h
loads.append(plugin)
h.log("reload", plugin_id)
h.pm.plugins[plugin_id] = plugin
h.pm.plugin_manifests[plugin_id] = {"version": f"{len(loads)}.0.0"}
return True
h.pm.reload_plugin = reload_plugin
h.reload_seconds = 3.0
server = h.control_socket()
first = server.post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"}, "a")
second = server.post(POSTED + 1, Command.PLUGIN_RELOAD, {"plugin_id": "clock"}, "b")
h.run()
# The second starts once the first has joined the rotation (within a
# quarter second of loading), and takes its own 3 s.
loaded = [e[0] for e in h.events if e[1] == "reload"]
assert len(loaded) == 2
assert loaded[0] == pytest.approx(POSTED + 3.0, abs=0.05)
assert 3.0 <= loaded[1] - loaded[0] <= 3.5
assert first.outcome.result["version"] == "1.0.0"
assert second.outcome.result["version"] == "2.0.0"
assert h.controller.plugin_modes["clock"] is loads[-1]
assert _vegas_frame_gaps(h) <= 0.1
def test_a_reconcile_during_a_reload_does_not_load_it_twice(dc):
"""The config watcher sees an enabled plugin missing from the rotation
while it reloads; the reconcile must not load it beside the reload."""
pm = MagicMock()
pm.discover_plugins.return_value = ["slow"]
dc.plugin_manager = pm
dc.config_service = MagicMock()
dc.config_service.get_config.return_value = {"slow": {"enabled": True}}
dc.plugin_display_modes = {}
dc._plugin_reload_jobs = (SimpleNamespace(plugin_id="slow"),)
assert dc._reconcile_enabled_plugins() is True
pm.load_plugin.assert_not_called()
assert not dc._enabled_plugin_not_running({"slow": {"enabled": True}})
# Disabled while it reloads: unloaded by a reconcile after the reload.
dc.config_service.get_config.return_value = {"slow": {"enabled": False}}
assert dc._reconcile_enabled_plugins() is True
pm.unload_plugin.assert_not_called()
assert dc._reconcile_after_reload is True
def test_on_demand_for_a_reloading_plugin_is_refused(dc):
dc._plugin_reload_jobs = (SimpleNamespace(plugin_id="slow"),)
dc._load_plugin_for_on_demand = MagicMock()
dc._activate_on_demand({"plugin_id": "slow"})
dc._load_plugin_for_on_demand.assert_not_called()
assert dc.on_demand_last_error == "plugin-reloading"