From 48a38f308250c07b177fb5e9c04a7f7078831d66 Mon Sep 17 00:00:00 2001 From: Anton <79713657+axectly@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:42:40 +0300 Subject: [PATCH 1/3] Keep a stat thread alive when its scheduled job fails Every stat is scheduled in its own thread with its own sched.scheduler. An exception raised by the job propagates out of scheduler.run(), so that thread dies and its widget freezes on screen until the program is restarted. Nothing is logged when this happens: the thread exception goes to sys.stderr, which is None in the packaged windowed executable. Catch exceptions around the job instead. The next run is already scheduled at that point, so the stat simply skips one cycle and recovers. Failures are now logged, rate-limited to one message per second per job because some jobs (e.g. QueueHandler) run every millisecond. Co-Authored-By: Claude Opus 5 --- library/scheduler.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/library/scheduler.py b/library/scheduler.py index d6a1edd54..c774cfbc9 100644 --- a/library/scheduler.py +++ b/library/scheduler.py @@ -28,9 +28,15 @@ import library.config as config import library.stats as stats +from library.log import logger STOPPING = False +# Some jobs run every millisecond, so a job failing at every run would flood the log file: +# only log the same failing job once per this delay (in seconds). +ERROR_LOG_MIN_INTERVAL = 1.0 +_last_error_log = {} + def async_job(threadname=None): """ wrapper to handle asynchronous threads """ @@ -62,7 +68,16 @@ def periodic(scheduler, periodic_interval, action, actionargs=()): # If the program is not stopping: re-schedule the task for future execution scheduler.enter(periodic_interval, 1, periodic, (scheduler, periodic_interval, action, actionargs)) - action(*actionargs) + # A failing sensor read must not kill this stat's thread: the next run is already + # scheduled above, so log the error and keep going. + try: + action(*actionargs) + except Exception: + name = getattr(action, "__name__", str(action)) + now = time.monotonic() + if now - _last_error_log.get(name, 0.0) >= ERROR_LOG_MIN_INTERVAL: + _last_error_log[name] = now + logger.exception("Scheduled job '%s' failed, skipping this cycle", name) @wraps(func) def wrap( From 0dbcbecb764422ce9ee652741f3b4b14a4b5c63f Mon Sep 17 00:00:00 2001 From: Anton <79713657+axectly@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:42:40 +0300 Subject: [PATCH 2/3] Handle sensors returning None instead of a number ping3 returns None on timeout (and False for an unknown host), so Ping.stats raised TypeError on int(delay) as soon as one ping timed out, and the None was also stored in the line graph history. DisplayLineGraph calls math.isnan() on every stored value, which raises TypeError on None, so subsequent redraws of that graph failed too, for as long as the value stayed in the history buffer. Combined with a scheduled job exception killing its thread, a single ping timeout was enough to freeze the ping widget permanently. Normalize a missing ping delay to math.nan, which is what this code already uses as its "no data" marker (see last_values_list), and skip the widgets that need a number for that cycle. Guard save_last_value too, so any sensor returning None cannot poison a graph history. Co-Authored-By: Claude Opus 5 --- library/stats.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/library/stats.py b/library/stats.py index 9abdc6f48..3e0e554b6 100644 --- a/library/stats.py +++ b/library/stats.py @@ -29,7 +29,7 @@ import platform import random import sys -from typing import List +from typing import List, Optional import babel.dates import requests @@ -245,10 +245,14 @@ def display_themed_line_graph(theme_data, values): ) -def save_last_value(value: float, last_values: List[float], history_size: int): +def save_last_value(value: Optional[float], last_values: List[float], history_size: int): # Initialize last values list the first time with given size if len(last_values) != history_size: last_values[:] = last_values_list(size=history_size) + # A sensor may transiently return None: store it as the nan "no data" marker, else + # math.isnan() in DisplayLineGraph raises TypeError on every later redraw of this graph. + if value is None: + value = math.nan # Store the value to the list that can then be used for line graph last_values.append(value) # Also remove the oldest value from list @@ -933,22 +937,26 @@ def stats(cls): delay = random.randint(5, 120) else: delay = ping(dest_addr=PING_DEST, unit="ms") + if delay is None or delay is False: + # ping3 returns None on timeout and False on unknown host: no delay to display + delay = math.nan save_last_value(delay, cls.last_values_ping, theme_data['LINE_GRAPH'].get("HISTORY_SIZE", DEFAULT_HISTORY_SIZE)) # logger.debug(f"Ping delay: {delay}ms") - display_themed_progress_bar(theme_data['GRAPH'], delay) - display_themed_radial_bar( - theme_data=theme_data['RADIAL'], - value=int(delay), - unit="ms", - min_size=6 - ) - display_themed_value( - theme_data=theme_data['TEXT'], - value=int(delay), - unit="ms", - min_size=6 - ) + if not math.isnan(delay): + display_themed_progress_bar(theme_data['GRAPH'], delay) + display_themed_radial_bar( + theme_data=theme_data['RADIAL'], + value=int(delay), + unit="ms", + min_size=6 + ) + display_themed_value( + theme_data=theme_data['TEXT'], + value=int(delay), + unit="ms", + min_size=6 + ) display_themed_line_graph(theme_data['LINE_GRAPH'], cls.last_values_ping) From 481a042d7f9829917d796c27bb57fd9fc262ce92 Mon Sep 17 00:00:00 2001 From: Anton <79713657+axectly@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:23:44 +0300 Subject: [PATCH 3/3] Do not crash a stat thread when a sensor value is nan Sensors already use math.nan to report "no value" (LibreHardwareMonitor does it for CPU load and fan speed when it cannot read them), but the display helpers convert the value with int(), which raises ValueError on nan and kills the thread of that stat, freezing its widgets until the program is restarted: File "library/stats.py", line 208, in display_themed_percent_radial_bar ValueError: cannot convert float NaN to integer For the radial bar and the text value the conversion also happens before the SHOW check inside the called function, so a hidden widget crashes the stat too. Skip drawing when there is no value, as reported in #718. Co-Authored-By: Claude Opus 5 --- library/stats.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/library/stats.py b/library/stats.py index 3e0e554b6..08f12cad4 100644 --- a/library/stats.py +++ b/library/stats.py @@ -122,6 +122,11 @@ def display_themed_value(theme_data, value, min_size=0, unit=''): def display_themed_percent_value(theme_data, value): + # A sensor can transiently return nan: int(nan) raises ValueError, which would kill this + # stat's thread. Note this happens before the SHOW check below, i.e. even when hidden. + if value is None or math.isnan(value): + return + display_themed_value( theme_data=theme_data, value=int(value), @@ -131,6 +136,9 @@ def display_themed_percent_value(theme_data, value): def display_themed_temperature_value(theme_data, value): + if value is None or math.isnan(value): + return + display_themed_value( theme_data=theme_data, value=int(value), @@ -143,6 +151,9 @@ def display_themed_progress_bar(theme_data, value): if not theme_data.get("SHOW", False): return + if value is None or math.isnan(value): + return + display.lcd.DisplayProgressBar( x=theme_data.get("X", 0), y=theme_data.get("Y", 0), @@ -202,6 +213,9 @@ def display_themed_radial_bar(theme_data, value, min_size=0, unit='', custom_tex def display_themed_percent_radial_bar(theme_data, value): + if value is None or math.isnan(value): + return + display_themed_radial_bar( theme_data=theme_data, value=int(value), @@ -211,6 +225,9 @@ def display_themed_percent_radial_bar(theme_data, value): def display_themed_temperature_radial_bar(theme_data, value): + if value is None or math.isnan(value): + return + display_themed_radial_bar( theme_data=theme_data, value=int(value),