feat(vegas): render plugin content off the render thread, and keep it off the GIL when the panel needs it (#630)

DisplayManager.offscreen() gives a thread its own canvas, so Vegas renders every plugin's ticker content on its prefetch thread instead of pausing the scroll for canvas-bound plugins on the render thread. A render gate (src/common/render_gate.py, vegas_scroll.prefetch_gate, on by default with the GIL-releasing binding) lets the prefetch thread run Python only while the render thread waits in SwapOnVSync: on hdpi, frames 2+ refreshes late fell eightfold and late frames overall from 0.90% to 0.60%. See docs/OFFSCREEN_RENDERING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:57:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 865d62f67b
commit 9964dd2183
13 changed files with 1903 additions and 89 deletions
+114 -57
View File
@@ -8,7 +8,7 @@ implement get_vegas_content() and fallback capture of display() output.
import logging
import threading
import time
from contextlib import nullcontext
from contextlib import contextmanager, nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image
@@ -33,7 +33,13 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display()
"""
def __init__(self, display_manager: Any, config: Optional[Any] = None):
#: How long a background fetch waits for a plugin's update() to finish
#: before skipping the plugin this round. Off the render thread waiting
#: costs nothing visible; it only delays that one plugin's content.
PLUGIN_LOCK_TIMEOUT = 2.0
def __init__(self, display_manager: Any, config: Optional[Any] = None,
plugin_manager: Optional[Any] = None):
"""
Initialize the plugin adapter.
@@ -42,8 +48,13 @@ class PluginAdapter:
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
plugin_manager: Source of the per-plugin lock that keeps a
background fetch from running a plugin's display() while its
update() is mid-flight. Optional: without it, fetches take no
lock, as they always did.
"""
self.display_manager = display_manager
self.plugin_manager = plugin_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
@@ -91,13 +102,14 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
offscreen_only: The caller is off the render thread. Every content
path draws on a canvas of its own (DisplayManager.offscreen),
so all of them are safe there; the fetch also takes the
plugin's lock, waiting up to PLUGIN_LOCK_TIMEOUT for a running
update() to finish. With ``offscreen_prefetch`` switched off,
the old behaviour applies instead: paths that need a canvas
return None, leaving the caller to fetch the plugin on the
render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -117,11 +129,77 @@ class PluginAdapter:
)
return cached
# The old contract, kept behind the switch: background callers may
# not draw, so anything needing a canvas is left for the render thread.
restricted = offscreen_only and not getattr(
self.config, 'offscreen_prefetch', True)
if not offscreen_only or restricted:
return self._fetch_content(plugin, plugin_id, restricted)
with self._plugin_lock(plugin_id) as acquired:
if not acquired:
logger.warning(
"[%s] update() still running after %.0fs; skipping it this "
"round", plugin_id, self.PLUGIN_LOCK_TIMEOUT
)
return None
return self._fetch_content(plugin, plugin_id, restricted=False)
@contextmanager
def _plugin_lock(self, plugin_id: str):
"""Hold the plugin's update/display lock, waiting a bounded time.
Yields whether it was acquired. Yields True, holding nothing, when
there is no plugin manager to ask -- the behaviour before the lock was
taken here at all.
"""
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
yield True
return
lock = self.plugin_manager.get_plugin_lock(plugin_id)
acquired = lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT)
try:
yield acquired
finally:
if acquired:
lock.release()
@contextmanager
def _isolated_canvas(self, width: Optional[int] = None):
"""A canvas for the plugin to draw on that nothing else sees.
DisplayManager.offscreen() gives the calling thread its own canvas, so
this is safe on any thread and leaves the shared canvas untouched.
Older display managers and test doubles without it get the previous
behaviour: capture on the shared canvas, narrowed with render_size,
then restore it -- which is only safe on the render thread.
"""
offscreen = getattr(self.display_manager, 'offscreen', None)
if offscreen is not None:
with offscreen(width):
yield
return
original_image = self.display_manager.image.copy()
try:
with self._capture(), self._render_at(width or self.display_width):
yield
finally:
self.display_manager.image = original_image
def _fetch_content(
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool
) -> Optional[List[Image.Image]]:
"""Every content path in order: native, scroll helper, display capture.
``restricted`` is the pre-offscreen contract for background callers:
skip every path that needs a canvas and return None instead.
"""
# Try native Vegas content method first
has_native = hasattr(plugin, 'get_vegas_content')
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id, offscreen_only)
content = self._get_native_content(plugin, plugin_id, restricted)
if content:
total_width = sum(img.width for img in content)
logger.debug(
@@ -134,7 +212,7 @@ class PluginAdapter:
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.debug("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
content = self._get_scroll_helper_content(plugin, plugin_id, restricted)
if content:
total_width = sum(img.width for img in content)
logger.debug(
@@ -145,8 +223,8 @@ class PluginAdapter:
if has_scroll_helper:
logger.debug("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
if restricted:
# Display capture needs a canvas; leave it to the caller.
logger.debug(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
@@ -682,7 +760,7 @@ class PluginAdapter:
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -711,22 +789,21 @@ class PluginAdapter:
plugin._vegas_render_width = render_width
try:
# capture_mode unconditionally, even at full width. Building
# Vegas content is an off-screen operation, but a plugin is free
# to call update_display() while doing it — and outside
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
# On a canvas of its own even at full width. Building Vegas
# content is an off-screen operation, but a plugin is free to
# call update_display() while doing it, and on the shared canvas
# that write would land on the hardware, flashing the panel
# mid-scroll.
if restricted:
# Restricted (offscreen_prefetch off): no canvas of our own,
# so no narrowing. _vegas_render_width is set regardless: a
# plugin reading get_vegas_render_width() still gets its
# narrow size, and one that only reads matrix.width renders
# full width and is trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
@@ -807,7 +884,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -841,7 +918,7 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
if restricted:
# Generating it calls display(), which needs the canvas.
logger.debug(
"[%s] scroll_helper cache empty; deferring generation "
@@ -991,12 +1068,8 @@ class PluginAdapter:
Returns:
The generated cached_image or None
"""
original_image = None
try:
# Save display state to restore after
original_image = self.display_manager.image.copy()
with self._capture():
with self._isolated_canvas():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.debug(
@@ -1052,11 +1125,6 @@ class PluginAdapter:
logger.exception("[%s] Error triggering scroll content", plugin_id)
return None
finally:
# Restore original display state
if original_image is not None:
self.display_manager.image = original_image
def _capture_display_content(
self, plugin: 'BasePlugin', plugin_id: str
) -> Optional[List[Image.Image]]:
@@ -1070,12 +1138,7 @@ class PluginAdapter:
Returns:
List with single captured image, or None
"""
original_image = None
try:
# Save current display state
original_image = self.display_manager.image.copy()
logger.debug("[%s] Fallback: saved original display state", plugin_id)
# Ensure plugin has fresh data before capturing
has_update_data = hasattr(plugin, 'update_data')
logger.debug("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
@@ -1086,12 +1149,12 @@ class PluginAdapter:
except (AttributeError, RuntimeError, OSError):
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
# Clear and call plugin display on a canvas of its own: nothing it
# draws, and no update_display() it calls, reaches the panel.
#
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
# The canvas is render_width wide, so a plugin that spreads across
# the whole panel produces a compact arrangement rather than one
# that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.debug(
@@ -1099,7 +1162,7 @@ class PluginAdapter:
plugin_id, render_width, self.display_width
)
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
self.display_manager.clear()
logger.debug("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -1133,7 +1196,7 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
@@ -1170,12 +1233,6 @@ class PluginAdapter:
)
return None
finally:
# Always restore original image to prevent display corruption
if original_image is not None:
self.display_manager.image = original_image
logger.debug("[%s] Fallback: restored original display state", plugin_id)
def _is_blank_image(
self, img: Image.Image, return_ratio: bool = False
) -> Union[bool, Tuple[bool, float]]: