mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
84043468 also gated the ESPN chunk fetches and the background data
service's workers, for the hourly sports refresh. A burst test on hdpi
(baseball and football refreshing every 5 minutes, 10-minute soaks, G F F G):
G prefetch gated only 0.87%, 0.83% late; 6+ late 20, 16; fetches 0.3-1.8s
F + fetch threads gated 1.23%, 1.05% late; 6+ late 12, 16; fetches 1.6-4.6s
Every parked fetch thread wakes at each swap and has to take the GIL again
just to park at the end of the window, so twenty of them cost more than
they saved, and the fetches ran two to three times as long. The plugins'
own copies of espn_dates (half the burst) were never gated anyway.
espn_dates and the background data service go back to main's versions and
the module-level active gate goes. Kept from that commit: the render thread
is never gated, a live refresh from another thread can't take its place,
and nested blocks keep the outer boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
433 lines
15 KiB
Python
433 lines
15 KiB
Python
"""The render gate (src/common/render_gate.py) and its wiring into Vegas."""
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from src.common import render_gate # noqa: E402
|
|
from src.common.render_gate import RenderGate # noqa: E402
|
|
|
|
PERIOD = 0.010
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.now = 100.0
|
|
|
|
def __call__(self):
|
|
return self.now
|
|
|
|
|
|
def _running(gate, clock, swaps=render_gate.MIN_SAMPLES + 1, hold=1):
|
|
"""Drive ``swaps`` on-time swaps through the gate, a refresh apart."""
|
|
for _ in range(swaps):
|
|
gate.before_swap(hold)
|
|
clock.now += hold * PERIOD
|
|
gate.after_swap(hold)
|
|
|
|
|
|
class TestWindow:
|
|
def test_no_window_until_the_period_is_known(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock, swaps=3)
|
|
assert gate.refresh_period() is None
|
|
gate.before_swap(1)
|
|
assert gate._open_until == 0.0
|
|
# ...and nothing parks meanwhile.
|
|
assert not gate._should_park(sys._getframe(), clock.now + 0.009)
|
|
|
|
def test_the_period_is_the_low_end_of_the_swap_gaps(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock, swaps=20)
|
|
gate.before_swap(1)
|
|
clock.now += 0.030 # one late frame lengthens its gap only
|
|
gate.after_swap(1)
|
|
assert gate.refresh_period() == pytest.approx(PERIOD)
|
|
|
|
def test_opens_until_just_before_the_refresh_the_swap_returns_on(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
last = clock.now
|
|
clock.now += 0.003 # the render thread's own work
|
|
gate.before_swap(1)
|
|
assert gate._open_until == pytest.approx(
|
|
last + PERIOD - render_gate.MARGIN_SECONDS)
|
|
|
|
def test_a_held_frame_opens_for_the_whole_hold(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock, hold=2)
|
|
last = clock.now
|
|
clock.now += 0.003
|
|
gate.before_swap(2)
|
|
assert gate._open_until == pytest.approx(
|
|
last + 2 * PERIOD - render_gate.MARGIN_SECONDS)
|
|
|
|
def test_a_late_frame_opens_until_the_next_boundary(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
last = clock.now
|
|
clock.now += 0.013 # missed its refresh: returns on the next
|
|
gate.before_swap(1)
|
|
assert gate._open_until == pytest.approx(
|
|
last + 2 * PERIOD - render_gate.MARGIN_SECONDS)
|
|
|
|
def test_closed_the_moment_the_swap_returns(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
gate.before_swap(1)
|
|
clock.now += PERIOD
|
|
gate.after_swap(1)
|
|
assert gate._open_until == 0.0
|
|
|
|
|
|
class TestWhenToPark:
|
|
@pytest.fixture
|
|
def gate(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
gate.before_swap(1)
|
|
return gate
|
|
|
|
def test_runs_inside_the_window(self, gate):
|
|
assert not gate._should_park(sys._getframe(), gate._open_until - 0.001)
|
|
|
|
def test_parks_once_it_has_closed(self, gate):
|
|
assert gate._should_park(sys._getframe(), gate._open_until + 0.0001)
|
|
|
|
def test_never_with_no_render_loop_to_protect(self, gate):
|
|
stale = gate._last_return + render_gate.STALE_SECONDS + 0.001
|
|
assert not gate._should_park(sys._getframe(), stale)
|
|
|
|
def test_never_holding_a_guarded_lock(self, gate):
|
|
rlock, lock = threading.RLock(), threading.Lock()
|
|
gate.guard(rlock, lock, None)
|
|
closed = gate._open_until + 0.0001
|
|
with rlock:
|
|
assert not gate._should_park(sys._getframe(), closed)
|
|
with lock:
|
|
assert not gate._should_park(sys._getframe(), closed)
|
|
assert gate._should_park(sys._getframe(), closed)
|
|
|
|
def test_an_rlock_held_by_another_thread_is_not_this_ones(self, gate):
|
|
rlock = threading.RLock()
|
|
gate.guard(rlock)
|
|
taken, done = threading.Event(), threading.Event()
|
|
|
|
def hold():
|
|
with rlock:
|
|
taken.set()
|
|
done.wait(5)
|
|
other = threading.Thread(target=hold)
|
|
other.start()
|
|
try:
|
|
taken.wait(5)
|
|
assert gate._should_park(sys._getframe(), gate._open_until + 0.0001)
|
|
finally:
|
|
done.set()
|
|
other.join()
|
|
|
|
@staticmethod
|
|
def _frame_in(module):
|
|
namespace = {"__name__": module}
|
|
exec("import sys\ndef here():\n return sys._getframe()\n", namespace)
|
|
return namespace["here"]()
|
|
|
|
@pytest.mark.parametrize("module", [
|
|
"logging", "logging.handlers", "threading", "importlib",
|
|
"_frozen_importlib", "_frozen_importlib_external",
|
|
"src.cache_manager", "src.cache.disk_cache",
|
|
])
|
|
def test_never_inside_code_that_takes_shared_locks(self, gate, module):
|
|
frame = self._frame_in(module)
|
|
assert render_gate._unsafe(frame, None)
|
|
assert not gate._should_park(frame, gate._open_until + 0.0001)
|
|
|
|
@pytest.mark.parametrize("module", [
|
|
"src.cache_helpers", "plugin_logging_ticker", "src.vegas_mode.render_pipeline",
|
|
])
|
|
def test_modules_that_only_sound_alike_are_fine(self, module):
|
|
assert not render_gate._unsafe(self._frame_in(module), None)
|
|
|
|
def test_where_python_is_installed_does_not_matter(self):
|
|
# GitHub's runners keep Python under /opt/hostedtoolcache; matching
|
|
# paths for "cache" made every stdlib frame unsafe there.
|
|
namespace = {"__name__": "json.decoder"}
|
|
exec(compile("import sys\ndef here():\n return sys._getframe()\n",
|
|
"/opt/hostedtoolcache/Python/3.11/lib/json/decoder.py", "exec"),
|
|
namespace)
|
|
assert not render_gate._unsafe(namespace["here"](), None)
|
|
|
|
def test_what_lies_below_the_yielding_block_does_not_count(self):
|
|
# A thread's stack always starts in threading.py; only frames above
|
|
# the one that entered yielding() matter.
|
|
namespace = {"__name__": "threading"}
|
|
exec("def bootstrap(fn):\n return fn()\n", namespace)
|
|
|
|
def entered():
|
|
base = sys._getframe()
|
|
|
|
def work():
|
|
top = sys._getframe()
|
|
return render_gate._unsafe(top, None), render_gate._unsafe(top, base)
|
|
return work()
|
|
assert namespace["bootstrap"](entered) == (True, False)
|
|
|
|
|
|
class TestYielding:
|
|
"""A real background thread, parked and released by the gate."""
|
|
|
|
def _worker(self, gate, stop):
|
|
count = [0]
|
|
|
|
def step():
|
|
count[0] += 1
|
|
|
|
def work():
|
|
with gate.yielding():
|
|
while not stop.is_set():
|
|
step()
|
|
thread = threading.Thread(target=work, daemon=True)
|
|
return thread, count
|
|
|
|
def test_parks_while_closed_and_runs_while_open(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
gate.before_swap(1)
|
|
stop = threading.Event()
|
|
thread, count = self._worker(gate, stop)
|
|
try:
|
|
clock.now = gate._open_until + 0.001 # window closed
|
|
thread.start()
|
|
time.sleep(0.2)
|
|
parked_steps = count[0]
|
|
# At most one step per MAX_WAIT timeout while parked.
|
|
assert parked_steps < 0.2 / render_gate.MAX_WAIT_SECONDS + 5
|
|
assert gate.parks >= 1
|
|
|
|
with gate._cond: # the next swap opens it
|
|
gate._open_until = clock.now + 3600.0
|
|
gate._generation += 1
|
|
gate._cond.notify_all()
|
|
time.sleep(0.1)
|
|
assert count[0] > parked_steps + 1000
|
|
finally:
|
|
stop.set()
|
|
with gate._cond:
|
|
gate._open_until = float("inf")
|
|
gate._generation += 1
|
|
gate._cond.notify_all()
|
|
thread.join(2)
|
|
assert not thread.is_alive()
|
|
|
|
def test_runs_freely_once_the_render_loop_stops(self):
|
|
clock = Clock()
|
|
gate = RenderGate(clock)
|
|
_running(gate, clock)
|
|
stop = threading.Event()
|
|
thread, count = self._worker(gate, stop)
|
|
clock.now += render_gate.STALE_SECONDS + 0.01
|
|
thread.start()
|
|
time.sleep(0.1)
|
|
stop.set()
|
|
thread.join(2)
|
|
assert count[0] > 1000
|
|
assert gate.parks == 0
|
|
|
|
def test_the_hook_comes_off_when_the_block_ends(self):
|
|
gate = RenderGate()
|
|
before = sys.getprofile()
|
|
with gate.yielding():
|
|
assert sys.getprofile() == gate._hook
|
|
assert sys.getprofile() is before
|
|
|
|
|
|
class TestWhoIsGated:
|
|
def _in_thread(self, fn):
|
|
out = []
|
|
thread = threading.Thread(target=lambda: out.append(fn()))
|
|
thread.start()
|
|
thread.join(5)
|
|
return out[0]
|
|
|
|
def test_the_render_thread_never_gives_way(self):
|
|
gate = RenderGate()
|
|
gate.before_swap(1)
|
|
gate.after_swap(1) # this thread swaps: it is the render thread
|
|
with gate.yielding():
|
|
assert sys.getprofile() is not gate._hook
|
|
|
|
def background():
|
|
with gate.yielding():
|
|
return sys.getprofile() == gate._hook
|
|
assert self._in_thread(background)
|
|
|
|
def test_a_live_refresh_from_another_thread_does_not_take_its_place(self):
|
|
gate = RenderGate()
|
|
gate.after_swap(1) # the render loop
|
|
self._in_thread(lambda: gate.after_swap(1)) # a plugin pushing a frame
|
|
with gate.yielding():
|
|
assert sys.getprofile() is not gate._hook
|
|
|
|
def test_nested_blocks_keep_the_outer_boundary(self):
|
|
gate = RenderGate()
|
|
|
|
def nested():
|
|
with gate.yielding():
|
|
outer = gate._local.base
|
|
with gate.yielding():
|
|
inner = gate._local.base
|
|
after = gate._local.base
|
|
still_hooked = sys.getprofile() == gate._hook
|
|
return outer is inner is after and still_hooked, sys.getprofile()
|
|
kept, final = self._in_thread(nested)
|
|
assert kept and final is None
|
|
|
|
|
|
class TestDisplayManager:
|
|
@pytest.fixture
|
|
def dm(self):
|
|
from src.display_manager import DisplayManager
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
manager = DisplayManager({"display": {
|
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 1, "parallel": 1},
|
|
"runtime": {"gpio_slowdown": 0}}}, suppress_test_pattern=True)
|
|
yield manager
|
|
manager.render_gate = None
|
|
manager.set_scrolling_state(False)
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
|
|
def test_opens_around_each_swap(self, dm):
|
|
calls = []
|
|
|
|
class Spy:
|
|
def before_swap(self, hold):
|
|
calls.append(("before", hold))
|
|
|
|
def after_swap(self, hold):
|
|
calls.append(("after", hold))
|
|
|
|
real_swap = dm.matrix.SwapOnVSync
|
|
|
|
def swap(canvas, *args, **kwargs):
|
|
calls.append(("swap",))
|
|
return real_swap(canvas, *args, **kwargs)
|
|
dm.matrix.SwapOnVSync = swap
|
|
dm.render_gate = Spy()
|
|
dm.set_scrolling_state(True, 2)
|
|
dm.draw.rectangle([0, 0, 3, 3], fill=(255, 0, 0))
|
|
dm.update_display()
|
|
assert calls == [("before", 2), ("swap",), ("after", 2)]
|
|
|
|
def test_off_screen_drawing_never_touches_it(self, dm):
|
|
class Boom:
|
|
def before_swap(self, hold):
|
|
raise AssertionError("an off-screen frame reached the gate")
|
|
after_swap = before_swap
|
|
|
|
dm.render_gate = Boom()
|
|
with dm.offscreen():
|
|
dm.draw.rectangle([0, 0, 3, 3], fill=(255, 0, 0))
|
|
dm.update_display()
|
|
|
|
|
|
class TestVegasWiring:
|
|
def _coordinator(self, **config):
|
|
from src.vegas_mode.config import VegasModeConfig
|
|
from src.vegas_mode.coordinator import VegasModeCoordinator
|
|
|
|
class Holder:
|
|
def __init__(self):
|
|
self._buffer_lock = threading.RLock()
|
|
self._prefetch_lock = threading.Lock()
|
|
self._cache_lock = threading.Lock()
|
|
|
|
c = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
|
c.vegas_config = VegasModeConfig(**config)
|
|
c._state_lock = threading.Lock()
|
|
c.stream_manager = Holder()
|
|
c.render_pipeline = Holder()
|
|
c.plugin_adapter = Holder()
|
|
c.display_manager = type("DM", (), {"render_gate": None})()
|
|
return c
|
|
|
|
def test_on_by_default(self, monkeypatch):
|
|
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True)
|
|
c = self._coordinator()
|
|
c._install_render_gate()
|
|
assert isinstance(c.display_manager.render_gate, RenderGate)
|
|
|
|
def test_can_be_turned_off(self, monkeypatch):
|
|
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True)
|
|
c = self._coordinator(prefetch_gate=False)
|
|
c._install_render_gate()
|
|
assert c.display_manager.render_gate is None
|
|
|
|
def test_installed_for_the_run_and_removed_after(self, monkeypatch):
|
|
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True)
|
|
c = self._coordinator(prefetch_gate=True)
|
|
c._install_render_gate()
|
|
gate = c.display_manager.render_gate
|
|
assert isinstance(gate, RenderGate)
|
|
assert c._state_lock in gate._guarded
|
|
assert c.stream_manager._buffer_lock in gate._guarded
|
|
assert c.plugin_adapter._cache_lock in gate._guarded
|
|
c._remove_render_gate()
|
|
assert c.display_manager.render_gate is None
|
|
|
|
@pytest.mark.parametrize("releases", [False, None])
|
|
def test_ignored_without_a_binding_that_releases_the_gil(self, monkeypatch, releases):
|
|
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: releases)
|
|
c = self._coordinator(prefetch_gate=True)
|
|
c._install_render_gate()
|
|
assert c.display_manager.render_gate is None
|
|
|
|
def test_read_from_config(self):
|
|
from src.vegas_mode.config import VegasModeConfig
|
|
off = VegasModeConfig.from_config(
|
|
{"display": {"vegas_scroll": {"prefetch_gate": False}}})
|
|
assert off.prefetch_gate is False
|
|
assert off.to_dict()["prefetch_gate"] is False
|
|
assert VegasModeConfig.from_config(
|
|
{"display": {"vegas_scroll": {}}}).prefetch_gate is True
|
|
|
|
def test_the_prefetch_runs_inside_the_gate(self):
|
|
from src.vegas_mode.render_pipeline import RenderPipeline
|
|
|
|
gate = RenderGate()
|
|
seen = []
|
|
|
|
class Stream:
|
|
def take_next_group(self, offscreen_only=False):
|
|
seen.append((sys.getprofile() == gate._hook, offscreen_only))
|
|
return ["segment"]
|
|
|
|
pipeline = RenderPipeline.__new__(RenderPipeline)
|
|
pipeline.config = type("C", (), {"continuous_scroll": True})()
|
|
pipeline._prefetch_lock = threading.Lock()
|
|
pipeline._prefetch_thread = None
|
|
pipeline._prepared_group = None
|
|
pipeline.stream_manager = Stream()
|
|
pipeline.display_manager = type("DM", (), {"render_gate": gate})()
|
|
pipeline.start_prefetch()
|
|
pipeline._prefetch_thread.join(5)
|
|
assert seen == [(True, True)]
|
|
assert pipeline._prepared_group == ["segment"]
|