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
@@ -4,8 +4,9 @@ The invariants that keep this change safe:
|
||||
1. run_scheduled_updates returns immediately — a slow update() can never
|
||||
again freeze the render loop (the original defect: 30s scroll freezes).
|
||||
2. A plugin's update() and display() are NEVER concurrent — the per-plugin
|
||||
lock makes the old implicit no-overlap guarantee explicit (and, unlike
|
||||
before, holds it across the post-timeout window too).
|
||||
lock makes the old implicit no-overlap guarantee explicit, and it stays
|
||||
held through PluginExecutor's own timeout: a lingering, still-running
|
||||
update() keeps the lock even after PluginExecutor gives up waiting on it.
|
||||
3. Failure/timeout bookkeeping is unchanged (same executor, same
|
||||
_record_update_failure path, same last-update stamping).
|
||||
4. The kill switch (plugin_system.synchronous_updates) restores the
|
||||
@@ -138,6 +139,40 @@ class TestUpdateDisplayExclusion:
|
||||
assert plugin.display_calls > 10
|
||||
assert plugin.overlap_detected is False
|
||||
|
||||
def test_lock_held_through_timeout_until_real_update_finishes(self, pm):
|
||||
"""PluginExecutor's join(timeout) can return before the real
|
||||
update() call does -- the lingering daemon thread keeps running it.
|
||||
The plugin's lock must stay held for that whole real duration, not
|
||||
just PluginExecutor's bounded wait, or display() could run
|
||||
concurrently with a still-executing update()."""
|
||||
plugin = SlowPlugin(update_seconds=0.3)
|
||||
plugin_id = _install(pm, plugin)
|
||||
pm.plugin_executor.default_timeout = 0.05 # times out well before
|
||||
# update_seconds elapses
|
||||
|
||||
pm.run_scheduled_updates()
|
||||
|
||||
deadline = time.monotonic() + 2
|
||||
while not plugin.in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert plugin.in_update is True
|
||||
|
||||
# PluginExecutor's own timeout has now elapsed, but the real
|
||||
# update() (0.3s) is still running in its lingering daemon thread.
|
||||
time.sleep(0.15)
|
||||
assert plugin.in_update is True, "test setup: update should still be running"
|
||||
lock = pm.get_plugin_lock(plugin_id)
|
||||
assert lock.acquire(blocking=False) is False, \
|
||||
"lock must stay held through PluginExecutor's timeout while the real update() runs"
|
||||
|
||||
# Once the real update() genuinely finishes, the lock is released.
|
||||
deadline = time.monotonic() + 2
|
||||
while plugin.in_update and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
assert lock.acquire(blocking=False) is True
|
||||
lock.release()
|
||||
|
||||
def test_state_returns_to_enabled_after_update(self, pm):
|
||||
"""RUNNING is set at enqueue (blocks re-entry via can_execute) and
|
||||
must return to an executable state once the update finishes."""
|
||||
@@ -175,11 +210,49 @@ class TestFailurePaths:
|
||||
pm.get_plugin_lock(plugin_id).release()
|
||||
|
||||
def test_unloaded_while_queued_is_harmless(self, pm):
|
||||
plugin_id = _install(pm, SlowPlugin(update_seconds=0.1))
|
||||
pm._enqueue_update(plugin_id, time.time())
|
||||
del pm.plugins[plugin_id]
|
||||
time.sleep(0.3) # worker drains the item without crashing
|
||||
assert pm._update_worker is None or pm._update_worker.is_alive()
|
||||
"""Exercise the public unload_plugin() lifecycle rather than
|
||||
deleting pm.plugins directly: queue the target's update behind a
|
||||
deterministic blocker (occupying the single worker), unload the
|
||||
target while its item still sits queued, then release the blocker
|
||||
and confirm the target's update never ran and its state stayed
|
||||
unloaded rather than being resurrected to ENABLED."""
|
||||
blocker_event = threading.Event()
|
||||
|
||||
class BlockerPlugin(SlowPlugin):
|
||||
def update(self):
|
||||
self.update_calls += 1
|
||||
blocker_event.wait(timeout=5)
|
||||
return True
|
||||
|
||||
blocker_id = _install(pm, BlockerPlugin(), plugin_id="blocker-plugin")
|
||||
target = SlowPlugin(update_seconds=0.05)
|
||||
target_id = _install(pm, target, plugin_id="slow-plugin")
|
||||
|
||||
# Dispatch the blocker first so it occupies the single worker
|
||||
# thread, then enqueue the target behind it -- deterministically
|
||||
# queued, not yet started.
|
||||
pm._enqueue_update(blocker_id, time.time())
|
||||
deadline = time.monotonic() + 2
|
||||
while pm.plugins[blocker_id].update_calls == 0 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert pm.plugins[blocker_id].update_calls == 1
|
||||
|
||||
pm._enqueue_update(target_id, time.time())
|
||||
assert target_id in pm._pending_updates
|
||||
|
||||
assert pm.unload_plugin(target_id) is True
|
||||
assert target_id not in pm.plugins
|
||||
|
||||
blocker_event.set() # let the blocker finish; worker moves on to
|
||||
# the target's queued item
|
||||
|
||||
deadline = time.monotonic() + 3
|
||||
while target_id in pm._pending_updates and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
assert target.update_calls == 0, "update() must not run for an unloaded plugin"
|
||||
assert target_id not in pm._pending_updates
|
||||
assert pm.state_manager.get_state(target_id) == PluginState.UNLOADED
|
||||
|
||||
|
||||
class TestKillSwitch:
|
||||
|
||||
Reference in New Issue
Block a user