mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
fix(core): harden text-measurement caches; surface snapshot failures (#400)
* fix(core): harden text-measurement caches; surface snapshot failures Deep-dive findings, all three latent on every 24/7 install: - font_manager.metrics_cache and display_manager._text_width_cache were unbounded dicts keyed by (text, id(font)). Two problems: keys embed the measured TEXT, so ever-changing strings (a clock, a live score, a ticker) grow them without limit; and id()-keying without holding a reference means a garbage-collected font's id can be recycled by a DIFFERENT font, silently returning wrong widths/metrics (classic plugins create fonts per render, so this is reachable). Both caches are now LRU-bounded (1024) and pin the font in the entry so its id stays valid. metrics_cache also keyed on the text itself instead of hash(text), removing a collision path. - _write_snapshot_if_due logged failures at DEBUG — invisible at the default level. The snapshot's mtime is the web UI's display mirror AND its hardware-liveness proxy, so a quiet failure freezes the mirror and makes health checks lie (seen in the field: a stale root-owned /tmp file froze it for a day). Failures now WARN, rate- limited to once per 5 minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * test: sync mock cache manager signature with CacheManager.get test_circuit_breaker has been failing on main: plugin_health passes memory_ttl= to cache_manager.get(), and the conftest mock's signature was never updated — the same component/double drift class as the monitored_update bug (#392). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Chuck
parent
6edd80d9f3
commit
9e3b5f366e
+18
-5
@@ -35,6 +35,7 @@ import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
@@ -58,7 +59,13 @@ class FontManager:
|
||||
# Font discovery and catalog
|
||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
||||
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
||||
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
||||
# (clocks, live scores) would otherwise grow it forever. Entries
|
||||
# keep the font alive so its id() can't be recycled by a different
|
||||
# font object (which would silently return wrong metrics).
|
||||
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
||||
self._METRICS_CACHE_MAX = 1024
|
||||
|
||||
# Plugin font management
|
||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||
@@ -555,10 +562,14 @@ class FontManager:
|
||||
Returns:
|
||||
Tuple of (width, height, baseline_offset)
|
||||
"""
|
||||
cache_key = f"{hash(text)}_{id(font)}"
|
||||
# Key on the text itself (hash(text) could collide) + font identity;
|
||||
# the entry below keeps the font referenced so the id stays valid.
|
||||
cache_key = (text, id(font))
|
||||
|
||||
if cache_key in self.metrics_cache:
|
||||
return self.metrics_cache[cache_key]
|
||||
cached = self.metrics_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
self.metrics_cache.move_to_end(cache_key)
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
@@ -595,7 +606,9 @@ class FontManager:
|
||||
baseline = 10
|
||||
|
||||
result = (width, height, baseline)
|
||||
self.metrics_cache[cache_key] = result
|
||||
self.metrics_cache[cache_key] = (result, font)
|
||||
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
||||
self.metrics_cache.popitem(last=False)
|
||||
return result
|
||||
|
||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||
|
||||
Reference in New Issue
Block a user