feat(vegas): ESPN fetches give way to the render thread too

The hourly sports refresh froze hdpi's Vegas scroll for 1.9s: about twenty
espn-chunk threads fetching and parsing at once, and the render thread
(and the stall watchdog) queued behind all of them for the GIL. The
prefetch gate only covered the prefetch thread.

Vegas now makes its gate the active one (render_gate.set_active), and
render_gate.yielding() gives way through it when there is one and does
nothing otherwise. espn_dates wraps each chunk fetch in it (behind the
same import fallback as json_body, for the copies plugins bundle), and the
background data service wraps each worker. The render thread is never
gated -- the first thread to swap is exempt, and a plugin pushing a live
refresh from its update thread cannot take its place -- and nested blocks
keep the outermost frame as the boundary for the lock checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 16:40:55 -04:00
co-authored by Claude Opus 5.5
parent a6648ec991
commit 84043468ca
5 changed files with 188 additions and 19 deletions
+98
View File
@@ -242,6 +242,102 @@ class TestYielding:
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 TestActiveGate:
@pytest.fixture(autouse=True)
def no_gate_left_behind(self):
yield
render_gate.set_active(None)
def test_without_a_vegas_run_nothing_is_gated(self):
render_gate.set_active(None)
with render_gate.yielding():
assert sys.getprofile() is None
def test_with_one_background_work_gives_way(self):
gate = RenderGate()
render_gate.set_active(gate)
with render_gate.yielding():
assert sys.getprofile() == gate._hook
assert sys.getprofile() is None
def test_espn_chunk_fetches_give_way(self):
from src.common import espn_dates
gate = RenderGate()
render_gate.set_active(gate)
seen = []
class Response:
content = b'{"events": []}'
def raise_for_status(self):
pass
def json(self):
return {"events": []}
class Session:
def get(self, *args, **kwargs):
seen.append(sys.getprofile() == gate._hook)
return Response()
assert espn_dates._fetch_one_chunk(
Session(), "http://x", {}, {}, 5, None, "20260924") == {"events": []}
assert seen == [True]
def test_background_data_fetches_give_way(self):
from src.background_data_service import BackgroundDataService
gate = RenderGate()
render_gate.set_active(gate)
service = BackgroundDataService.__new__(BackgroundDataService)
service._shutdown = True # never started: nothing for __del__ to stop
seen = []
service._fetch_data = lambda request: seen.append(
sys.getprofile() == gate._hook) or "result"
assert service._fetch_data_worker(object()) == "result"
assert seen == [True]
class TestDisplayManager:
@pytest.fixture
def dm(self):
@@ -332,8 +428,10 @@ class TestVegasWiring:
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
assert render_gate.active() is gate # background fetches use it too
c._remove_render_gate()
assert c.display_manager.render_gate is None
assert render_gate.active() is None
@pytest.mark.parametrize("releases", [False, None])
def test_ignored_without_a_binding_that_releases_the_gil(self, monkeypatch, releases):