mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes - font_manager: a .zip font URL is served as its extracted font after a restart (the cached-file check returned the archive first); downloads use requests with a 30s timeout into a temp file + os.replace. - api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use time.monotonic(); last_request_time and the status file's ts stay wall-clock. set_on_new_cycle docstring no longer claims core uses it. - logo_helper: the placeholder uses the same scaled box as a real logo. - permission_utils: one _sudo_bash_candidates() helper (with the sudoers exact-argv rationale) shared by sudo_remove_directory, which now retries the next bash path on a sudo refusal, and install_requirements_file. - dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate INFO log and contradictory docstring example fixed. - element_style: scale default looked up through element aliases. - background_data_service: cache-hit callback runs outside the lock. - config_arrays: union-aware type check (["array","null"]); stale dotToNested() reference removed. - auto_update_setup: non-dict auto_update reads as off; temp result file unlinked when the write fails. - exceptions: constructors copy the caller's context dict. - logging_config: StructuredFormatter json.dumps(default=str). - error_aggregator: removed unused export_path/export_to_file/_auto_export. - Docstrings: validate_file_upload max_size_mb, raise_on_errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): retry the status-file rename like the other atomic writers On Windows os.replace can fail with "Access is denied" while a scanner briefly holds the target open; config_manager_atomic._replace already retries that (and re-raises at once on other platforms). The sync status writer called os.replace directly, which made test_concurrent_writers_each_use_their_own_temp_file flaky on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+21
-10
@@ -32,6 +32,7 @@ from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.config_manager_atomic import _replace
|
||||
from src.display_geometry import DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_ROWS
|
||||
|
||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||
@@ -127,6 +128,9 @@ class DisplaySyncManager:
|
||||
self._peer_ip: Optional[str] = None
|
||||
self._peer_compatible: bool = False
|
||||
self._peer_chain: int = 0
|
||||
# time.monotonic() readings, like _last_leader_frame_time: these only
|
||||
# feed the timeout watchdogs, and a wall-clock step (NTP correcting a
|
||||
# Pi with no RTC) would otherwise fake or mask a timeout.
|
||||
self._last_heartbeat_time: float = 0.0
|
||||
self._leader_width: int = 0 # set by display_controller after init
|
||||
self._oversized_frame_warned: bool = False
|
||||
@@ -206,7 +210,7 @@ class DisplaySyncManager:
|
||||
self._handle_hello(msg, sender_ip)
|
||||
elif t == "hb":
|
||||
if self._peer_ip == sender_ip:
|
||||
self._last_heartbeat_time = time.time()
|
||||
self._last_heartbeat_time = time.monotonic()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
@@ -229,7 +233,7 @@ class DisplaySyncManager:
|
||||
self._peer_ip = sender_ip
|
||||
self._peer_compatible = compatible
|
||||
self._peer_chain = peer_chain
|
||||
self._last_heartbeat_time = time.time()
|
||||
self._last_heartbeat_time = time.monotonic()
|
||||
|
||||
prev_state = self._leader_state
|
||||
if compatible:
|
||||
@@ -273,7 +277,7 @@ class DisplaySyncManager:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._leader_state == LeaderState.CONNECTED:
|
||||
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
|
||||
if time.monotonic() - self._last_heartbeat_time > PEER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: follower heartbeat timeout — peer disconnected"
|
||||
)
|
||||
@@ -501,7 +505,7 @@ class DisplaySyncManager:
|
||||
"""Note that the leader at ``sender_ip`` just sent something, and
|
||||
switch from standalone to follower mode if not already following.
|
||||
Returns True if this call made the switch."""
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._last_leader_frame_time = time.monotonic()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state != FollowerState.STANDALONE:
|
||||
return False
|
||||
@@ -621,11 +625,13 @@ class DisplaySyncManager:
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
dest = ("<broadcast>", self.port)
|
||||
|
||||
last_hello = 0.0
|
||||
last_hb = 0.0
|
||||
# -inf, not 0.0: monotonic time starts near boot, so "now - 0.0" can
|
||||
# be under the interval and would delay the first announcement.
|
||||
last_hello = float("-inf")
|
||||
last_hb = float("-inf")
|
||||
|
||||
while self._running:
|
||||
now = time.time()
|
||||
now = time.monotonic()
|
||||
if now - last_hello >= HELLO_INTERVAL:
|
||||
try:
|
||||
self._send_sock.sendto(hello, dest)
|
||||
@@ -644,7 +650,7 @@ class DisplaySyncManager:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._follower_state == FollowerState.FOLLOWER:
|
||||
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
||||
if time.monotonic() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: leader frame timeout — returning to standalone mode"
|
||||
)
|
||||
@@ -670,7 +676,10 @@ class DisplaySyncManager:
|
||||
|
||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
||||
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
|
||||
|
||||
Nothing in core registers one: display_controller follows the leader
|
||||
through set_on_scroll_image() and the scroll position instead of
|
||||
rebuilding locally. The hook stays for callers that want the signal.
|
||||
"""
|
||||
self._on_new_cycle = callback
|
||||
|
||||
@@ -734,7 +743,9 @@ class DisplaySyncManager:
|
||||
# mkstemp makes it owner-only; the web UI may run as a
|
||||
# different user from the display service.
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, STATUS_FILE)
|
||||
# _replace: on Windows a rename can briefly fail with
|
||||
# "Access is denied" while a scanner holds the target open.
|
||||
_replace(tmp, STATUS_FILE)
|
||||
tmp = None
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: status file write error: %s", exc)
|
||||
|
||||
Reference in New Issue
Block a user