mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(sports): stop the idle back-off sleeping through a kickoff (#599)
* fix(sports): stop the idle back-off sleeping through a kickoff A league with no live games backs its poll off as empty checks mount, capped by live_idle_max_interval. The escalation counts empty looks and nothing else, so a league three hours before kickoff is indistinguishable from one three months out of season. Both reach the ceiling -- and the ceiling then *is* the blind spot. Measured on two rigs on 2026-09-19: gaps of up to 928s between looks, ten of them at or above 900s. Reproduced in the wild on 2026-09-20, where an unpatched rig sat for fifteen minutes with eight NFL games in progress and had not noticed any of them. That is the "it doesn't pick up new live games until I restart it" report -- restarting being the one thing that forces an immediate look. The clamp costs no extra request: the live fetch already downloads the whole day's scoreboard, upcoming games included, so the earliest start still ahead of us falls out of the payload the manager already has. Before a kickoff the wait is shortened so it cannot run past it; just after one, the live cadence is held for _KICKOFF_GRACE_SECONDS, because a provider that has not yet flipped the status would otherwise look like another empty check and escalate the back-off again, right when the game is starting. The grace window needed a second pass. A soak caught it as dead code: the just-passed kickoff was replaced by the next fixture on the card the instant it passed, `now < start` went true again, and the back-off returned to its ceiling. Observed live -- the rig polled at 13:00:45, found nothing because ESPN had not flipped the status, then went quiet for a quarter of an hour. A kickoff inside the grace window is now kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * test(sports): pin absolute tolerances and correct a wrong grace expectation pytest.approx defaults to a relative tolerance. On a unix timestamp that is roughly 1790 seconds, so every kickoff assertion here was effectively vacuous -- it called a kickoff half an hour away "equal". All seven now pin abs=1. That hid a wrong expectation. test_an_earlier_kickoff_still_wins_during_the_grace asserted a game ten minutes out should displace one that kicked off moments ago. It should not, and the code does not: while the grace holds, the wait is the live cadence (30s), which is strictly tighter than clamping to the nearer kickoff would give (~600s). Letting the candidate win would set a ten-minute wait at the exact moment games are starting -- the dead grace window this branch exists to fix. The test now pins the real behaviour plus the safety property that makes it correct, and is renamed to say what it checks. Reported by CodeRabbit on the PR. The finding was right that code and test disagreed; the suggested fix was the wrong way to resolve it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,13 @@ _IDLE_LONG_STREAK = 24
|
||||
_IDLE_LONG_FACTOR = 6
|
||||
_DEFAULT_LIVE_IDLE_MAX_SECONDS = 900
|
||||
|
||||
#: How long after a scheduled start to keep looking on the live cadence. ESPN
|
||||
#: does not flip a game to in-progress exactly at kickoff, and an escalated
|
||||
#: back-off treats each of those early looks as another empty one.
|
||||
_KICKOFF_GRACE_SECONDS = 900
|
||||
#: Fallback cadence around a kickoff when the manager has no update_interval.
|
||||
_KICKOFF_POLL_FLOOR = 30
|
||||
|
||||
|
||||
def _resolve_font_path(path: str) -> str:
|
||||
"""Resolve a bundled font path without depending on the process cwd.
|
||||
@@ -1310,6 +1317,11 @@ class SportsLiveSharedMixin:
|
||||
Capped rather than unbounded: the cost of backing off is how late the
|
||||
first game after a quiet spell is noticed, and past the cap the saving
|
||||
stops being worth that.
|
||||
|
||||
The escalation is then clamped by the next kickoff the league already
|
||||
knows about -- see _clamp_to_scheduled_start. Without that clamp the cap
|
||||
*is* the miss: a league idle overnight reaches the ceiling, and the
|
||||
first game of the next day is not noticed for up to that long.
|
||||
"""
|
||||
streak = getattr(self, "_empty_live_streak", 0)
|
||||
base = self.no_data_interval
|
||||
@@ -1321,10 +1333,87 @@ class SportsLiveSharedMixin:
|
||||
# *shrink* as the streak grew (3600s at streak 0, 900s at streak 24),
|
||||
# the opposite of what the setting named "maximum" promises.
|
||||
if streak >= _IDLE_LONG_STREAK:
|
||||
return min(int(base * _IDLE_LONG_FACTOR), ceiling)
|
||||
if streak >= _IDLE_SHORT_STREAK:
|
||||
return min(int(base * _IDLE_SHORT_FACTOR), ceiling)
|
||||
return min(base, ceiling)
|
||||
interval = min(int(base * _IDLE_LONG_FACTOR), ceiling)
|
||||
elif streak >= _IDLE_SHORT_STREAK:
|
||||
interval = min(int(base * _IDLE_SHORT_FACTOR), ceiling)
|
||||
else:
|
||||
interval = min(base, ceiling)
|
||||
return self._clamp_to_scheduled_start(interval)
|
||||
|
||||
def _clamp_to_scheduled_start(self, interval: int) -> int:
|
||||
"""Shorten an idle wait that would sleep through a known kickoff.
|
||||
|
||||
The back-off counts consecutive empty looks and nothing else, so it
|
||||
cannot tell an out-of-season league from an in-season one a few hours
|
||||
before kickoff. Both reach the ceiling, and the ceiling then becomes the
|
||||
blind spot: measured on two rigs on 2026-09-19, gaps of up to 928s
|
||||
between looks, 10 of them at or above 900s. A game starting inside such
|
||||
a gap is not noticed until it ends -- which is the "it doesn't pick up
|
||||
new live games until I restart it" report, restarting being the one
|
||||
thing that forces an immediate look.
|
||||
|
||||
The fix costs no extra request: the live fetch already downloads the
|
||||
whole day's scoreboard, upcoming games included, and
|
||||
_note_scheduled_start_candidate keeps the earliest start still ahead of
|
||||
us out of exactly that payload.
|
||||
|
||||
Two cases, either side of the kickoff:
|
||||
|
||||
* before it -- wait at most until it starts, never past it;
|
||||
* just after it -- hold the live cadence for _KICKOFF_GRACE_SECONDS,
|
||||
because a provider that has not yet flipped the status would
|
||||
otherwise look like another empty check and escalate the back-off
|
||||
again, right when the game is actually starting.
|
||||
"""
|
||||
start = getattr(self, "_next_scheduled_start_ts", None)
|
||||
if not start:
|
||||
return interval
|
||||
live = getattr(self, "update_interval", None) or _KICKOFF_POLL_FLOOR
|
||||
now = time.time()
|
||||
if now < start:
|
||||
return max(live, min(interval, int(start - now)))
|
||||
if now - start <= _KICKOFF_GRACE_SECONDS:
|
||||
return live
|
||||
return interval
|
||||
|
||||
def _note_scheduled_start_candidate(self, details) -> None:
|
||||
"""Offer a game from the current look as the next kickoff to wake for.
|
||||
|
||||
Called for every event the live fetch returns, live or not, so the
|
||||
earliest start still ahead of us falls out of the payload the manager
|
||||
already has. Self-correcting: a stored start that has passed is
|
||||
replaced by the next one offered, so a postponed game cannot pin the
|
||||
cadence to a kickoff that never happens.
|
||||
"""
|
||||
if not isinstance(details, dict):
|
||||
return
|
||||
if details.get("is_live") or details.get("is_halftime"):
|
||||
return
|
||||
start = details.get("start_time_utc")
|
||||
timestamp = getattr(start, "timestamp", None)
|
||||
if timestamp is None:
|
||||
return
|
||||
try:
|
||||
candidate = float(timestamp())
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return
|
||||
now = time.time()
|
||||
if candidate <= now:
|
||||
return
|
||||
current = getattr(self, "_next_scheduled_start_ts", None)
|
||||
# A kickoff that has only just passed is *kept*, not replaced by the
|
||||
# next one on the card. Replacing it immediately is what made the grace
|
||||
# window in _clamp_to_scheduled_start dead code: the moment 13:00 came
|
||||
# round, the stored start jumped to the 16:05 games, `now < start` went
|
||||
# true again, and the back-off returned to its ceiling -- at exactly the
|
||||
# moment the games were starting. Observed live on 2026-09-20: the rig
|
||||
# polled at 13:00:45, found nothing live because ESPN had not flipped
|
||||
# the status yet, and then went quiet for the next quarter of an hour,
|
||||
# which is the behaviour this whole clamp exists to prevent.
|
||||
if (current is None
|
||||
or current <= now - _KICKOFF_GRACE_SECONDS
|
||||
or candidate < current):
|
||||
self._next_scheduled_start_ts = candidate
|
||||
|
||||
def _note_live_fetch(self, found_live: bool) -> None:
|
||||
"""Record whether a look for live games found any."""
|
||||
|
||||
Reference in New Issue
Block a user