From 861d641868a96a95225c1d8f693e2a6cbe463791 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 e09c4f96436b7034e5bb7ea925e33cecea61876d 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 0bfcc96c2..8844571d8 100644 --- a/library/stats.py +++ b/library/stats.py @@ -28,7 +28,7 @@ import os import platform import sys -from typing import List +from typing import List, Optional import babel.dates import requests @@ -244,10 +244,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 @@ -925,22 +929,26 @@ def stats(cls): theme_data = config.THEME_DATA['STATS']['PING'] 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 f30d5c5ca284f58e6110bf92967d3e07874f3448 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 8844571d8..f6fa14b77 100644 --- a/library/stats.py +++ b/library/stats.py @@ -121,6 +121,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), @@ -130,6 +135,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), @@ -142,6 +150,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), @@ -201,6 +212,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), @@ -210,6 +224,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),