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>
This commit is contained in:
Chuck
2026-10-02 13:42:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5aa7a63127
commit 8136a2d525
9 changed files with 905 additions and 49 deletions
+65 -9
View File
@@ -70,6 +70,14 @@ class PluginManager:
# before tearing the instance down anyway.
UNLOAD_LOCK_TIMEOUT = 5.0
# How long unload_detached_plugin() (a live reload, off the render thread)
# waits for the old instance's lock. Longer than UNLOAD_LOCK_TIMEOUT
# because nothing is blocked by the wait, and a Vegas content build of the
# old instance can hold the lock for several seconds (5.9 s seen on
# ledpi). Past it the reload is refused rather than tearing down an
# instance a Vegas call may still be running in.
DETACHED_UNLOAD_LOCK_TIMEOUT = 30.0
# How long the update worker and apply_config_change() wait for a
# plugin's lock -- the same bound unload already uses for the same lock.
# A display() frame holds it for milliseconds, so this only runs out when
@@ -151,7 +159,9 @@ class PluginManager:
#
# Which thread runs each plugin hook, and what it holds:
# __init__, on_enable the loading thread (main thread at startup,
# the render thread on a live enable).
# the render thread on a live enable, a
# plugin-reload thread for a control socket
# reload).
# update() plugin-update-worker, under the plugin lock,
# via PluginExecutor (whose daemon thread runs
# the call; if it outlives the executor's
@@ -170,8 +180,10 @@ class PluginManager:
# plugin lock via apply_config_change(); if the
# lock stays busy it is deferred to the update
# worker, which applies it under the lock.
# cleanup(), on_disable() whoever calls unload_plugin(), under the
# lock with UNLOAD_LOCK_TIMEOUT.
# cleanup(), on_disable() whoever calls unload_plugin() (or, for a
# reload, unload_detached_plugin() on its
# plugin-reload thread), under the lock with
# UNLOAD_LOCK_TIMEOUT.
# No wait on a plugin lock is unbounded, so one hung plugin can only
# cost the worker PLUGIN_LOCK_TIMEOUT per attempt.
self._update_queue: "queue.Queue[Union[None, Tuple[str, float], _DeferredConfigChange]]" = queue.Queue()
@@ -752,14 +764,57 @@ class PluginManager:
if lock_acquired:
lock.release()
def _unload_plugin_locked(self, plugin_id: str) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock."""
if plugin_id not in self.plugins: # unloaded while we waited
def detach_plugin(self, plugin_id: str) -> Optional[Any]:
"""Take a loaded plugin out of ``plugins`` without tearing it down.
The first half of a reload that must not block its caller, the render
thread (DisplayController._start_plugin_reload). Every new call into a
plugin starts by looking it up in ``plugins``: the update scheduler,
the update worker (which looks again under the plugin's lock) and
Vegas's fetches. So once detached, nothing new reaches the instance.
Work already running on it under its lock -- an update(), or a Vegas
content render that can take seconds -- carries on;
unload_detached_plugin() waits for it, on another thread.
Returns the instance, or None when the plugin was not loaded.
"""
return self.plugins.pop(plugin_id, None)
def unload_detached_plugin(self, plugin_id: str, plugin: Any) -> bool:
"""Tear down an instance taken out by detach_plugin(): unload_plugin()
for an instance that is no longer in ``plugins``.
Waits for the plugin's lock, bounded by DETACHED_UNLOAD_LOCK_TIMEOUT,
so it belongs off the render thread. Call it before loading the plugin
again: it drops the plugin's modules and lifecycle state along with the
instance. Unlike unload_plugin() it never tears down without the lock:
a call that took the lock before the detach (a Vegas content build)
may still be running in this instance. Returns False then, and the
caller must not load the plugin again over it.
"""
lock = self.get_plugin_lock(plugin_id)
if not lock.acquire(timeout=self.DETACHED_UNLOAD_LOCK_TIMEOUT):
self.logger.warning(
"Plugin %s still busy after %.1fs; not unloading it while in use",
plugin_id, self.DETACHED_UNLOAD_LOCK_TIMEOUT)
return False
try:
return self._unload_plugin_locked(plugin_id, plugin)
finally:
lock.release()
def _unload_plugin_locked(self, plugin_id: str, detached: Optional[Any] = None) -> bool:
"""Body of unload_plugin(); caller holds (or gave up on) the plugin lock.
``detached`` is an instance already taken out of ``plugins``
(detach_plugin); without it, the loaded instance is unloaded.
"""
if detached is None and plugin_id not in self.plugins: # unloaded while we waited
self.logger.warning("Plugin %s not loaded", plugin_id)
return False
try:
plugin = self.plugins[plugin_id]
plugin = self.plugins[plugin_id] if detached is None else detached
# Call cleanup if available
if hasattr(plugin, 'cleanup'):
@@ -775,8 +830,9 @@ class PluginManager:
except Exception as e:
self.logger.warning("Error during plugin on_disable: %s", e)
# Remove from active plugins
del self.plugins[plugin_id]
# Remove from active plugins (a detached one already is)
if detached is None:
del self.plugins[plugin_id]
with self._deferred_config_lock:
self._deferred_config_changes.pop(plugin_id, None)
with self._plugin_last_update_lock: