Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 e2d865d41b fix(cache): count swept temp files as scanned
files_scanned only counted completed .json files, so a sweep that
removed orphans reported more deleted than it had looked at -- the
summary line renders "<deleted>/<scanned>", which came out as "76/1".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-11 18:41:36 -04:00
ChuckBuildsandClaude Opus 5 e03fbfe7b1 fix(cache): collect the temp files abandoned writes leave behind
DiskCache.set() writes through mkstemp then os.replace, and removes its
own temp file in a finally. That covers a write that fails, but not a
process that dies between the two -- a SIGKILL, a lost restart race, a
power cut, all ordinary on a Pi.

Nothing ever collected what was left. The temp names are
".<key>.json.<random>", and cleanup_expired_files listed only names
ending in .json, so every one of them was invisible to the sweep for as
long as the card had been in service. On the dev rig: 76 files,
1,050 MB, 81% of the whole cache directory, the oldest six months old.
The startup sweep reported "18/8864 files deleted, 0.01 MB freed" while
sitting on top of a gigabyte it could not see.

They are removed after an hour. A real write holds its temp file for
milliseconds, so that is far outside any in-flight write while still
clearing the same day's debris, and it is deliberately not tied to the
retention policies: those say how long data stays useful, and a
half-written file never was.

The predicate is tested harder than the sweep, because a false positive
deletes real data. It matches the shape set() creates rather than just a
leading dot, so a completed ".json", a stray .gitignore, and a
"weather.json.bak" are all left alone -- and one test drives set()
itself and asserts the names it produces are matched, so the writer and
the predicate cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-11 18:37:21 -04:00
4 changed files with 255 additions and 226 deletions
+64 -1
View File
@@ -14,6 +14,13 @@ import zlib
from typing import Dict, Any, Optional, Protocol
from datetime import datetime
# How old an abandoned write's temp file must be before the sweep removes it.
# A real write holds its temp file for milliseconds, so an hour is far beyond
# any in-flight write while still clearing the same day's debris. Deliberately
# not tied to the retention policies: those describe how long data stays
# useful, and a half-written file was never useful.
_ORPHAN_TEMP_MAX_AGE_SECONDS = 3600
class CacheStrategyProtocol(Protocol):
@@ -347,6 +354,23 @@ class DiskCache:
"""Get the cache directory path."""
return self.cache_dir
@staticmethod
def _is_orphaned_temp(filename: str) -> bool:
"""Whether a name is one of set()'s temp files rather than real data.
Matches only what this class creates: mkstemp with a prefix of
".<cache filename>." , so ".weather.json.a1b2c3d4". The shape is
checked rather than just the leading dot, because this predicate
deletes things -- a stray dotfile someone left in the cache directory
is not ours to remove, and a completed ".json" never is either.
"""
if not filename.startswith('.') or filename.endswith('.json'):
return False
head, sep, suffix = filename.rpartition('.json.')
# head is the key (non-empty after the leading dot), suffix is
# mkstemp's random component.
return bool(sep) and len(head) > 1 and bool(suffix)
def cleanup_expired_files(self, cache_strategy: CacheStrategyProtocol, retention_policies: Dict[str, int]) -> Dict[str, Any]:
"""
Clean up expired cache files based on retention policies.
@@ -381,11 +405,50 @@ class DiskCache:
try:
with self._lock:
# Get snapshot of files while holding lock briefly
filenames = [f for f in os.listdir(self.cache_dir) if f.endswith('.json')]
entries = os.listdir(self.cache_dir)
except OSError as list_error:
self.logger.error("Error listing cache directory %s: %s", self.cache_dir, list_error, exc_info=True)
stats['errors'] += 1
return stats
filenames = [f for f in entries if f.endswith('.json')]
# Sweep temp files abandoned by a write that never finished. set()
# removes its own in a finally, so these are the ones where the
# process died between mkstemp and os.replace -- a SIGKILL, a lost
# restart race, a power cut. Nothing ever collected them: they are
# named ".<key>.json.<random>", and the scan above only matches
# names ending in .json, so they accumulated indefinitely. Measured
# on a live rig: 76 files, 1,050 MB, 81% of the whole cache
# directory, the oldest six months old.
stats['orphan_temp_files_deleted'] = 0
for filename in (f for f in entries if self._is_orphaned_temp(f)):
# Counted as scanned like any other candidate, so files_deleted
# can never exceed files_scanned and the summary line reads
# honestly ("77/8864", not "77/0").
stats['files_scanned'] += 1
path = os.path.join(self.cache_dir, filename)
try:
# An in-flight write lives for milliseconds, so anything
# this old is certainly abandoned rather than in progress.
if (current_time - os.path.getmtime(path)) <= _ORPHAN_TEMP_MAX_AGE_SECONDS:
continue
with self._lock:
size = os.path.getsize(path)
os.remove(path)
stats['files_deleted'] += 1
stats['orphan_temp_files_deleted'] += 1
stats['space_freed_bytes'] += size
except FileNotFoundError:
continue # another sweep got there first
except OSError as e:
stats['errors'] += 1
self.logger.warning("Error deleting orphaned temp file %s: %s", filename, e)
if stats['orphan_temp_files_deleted']:
self.logger.info(
"Removed %d abandoned cache temp file(s)",
stats['orphan_temp_files_deleted'])
# Process files outside the lock to avoid blocking get/set operations
for filename in filenames:
+3 -106
View File
@@ -12,11 +12,8 @@ Supports three display modes per plugin:
"""
import logging
import os
import sys
import threading
import time
import traceback
import threading
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
from src.vegas_mode.config import VegasModeConfig
@@ -32,62 +29,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# A frame is a "hitch" once it takes this many times the typical frame. Two
# is deliberately forgiving: one dropped frame at 120fps is 8ms and invisible,
# whereas a marquee moving a steady few pixels per frame shows a stall of
# twice that as a visible jerk.
_HITCH_FACTOR = 2.0
# How many recent frames define "typical". Big enough to ride out noise, small
# enough to track a genuine change in what the loop costs.
_TYPICAL_SAMPLE = 60
# A stall long enough that a viewer sees the marquee stop dead. Frame-time
# statistics say one happened but not what did it, and by the time the numbers
# are logged the stack is long gone -- so a watchdog samples every thread while
# the loop is still wedged. Off unless LEDMATRIX_STALL_WATCHDOG is set, since
# it dumps a lot of text.
_STALL_DUMP_SECONDS = float(os.environ.get('LEDMATRIX_STALL_WATCHDOG', '0') or 0)
class _StallWatchdog:
"""Dumps every thread's stack when the render loop stops checking in."""
def __init__(self, threshold: float):
self.threshold = threshold
self._beat = time.time()
self._lock = threading.Lock()
self._stop = threading.Event()
self._dumped_for = 0.0
self._thread = threading.Thread(
target=self._watch, name="VegasStallWatchdog", daemon=True)
self._thread.start()
def beat(self) -> None:
with self._lock:
self._beat = time.time()
def stop(self) -> None:
self._stop.set()
def _watch(self) -> None:
while not self._stop.wait(self.threshold / 4.0):
with self._lock:
last = self._beat
stalled = time.time() - last
if stalled < self.threshold or last == self._dumped_for:
continue
self._dumped_for = last # one dump per stall, not per poll
frames = sys._current_frames()
names = {t.ident: t.name for t in threading.enumerate()}
lines = ["render loop stalled %.2fs -- thread stacks:" % stalled]
for ident, frame in frames.items():
lines.append(" --- %s (%s) ---" % (names.get(ident, "?"), ident))
for fn, lineno, func, _text in traceback.extract_stack(frame)[-8:]:
lines.append(" %s:%d in %s" % (fn, lineno, func))
logger.warning("\n".join(lines))
class VegasModeCoordinator:
"""
@@ -178,7 +119,6 @@ class VegasModeCoordinator:
'static_pauses': 0,
}
self._start_time: Optional[float] = None
self._stall_watchdog: Optional['_StallWatchdog'] = None
logger.info(
"VegasModeCoordinator initialized: enabled=%s, fps=%d, buffer_ahead=%d",
@@ -442,20 +382,6 @@ class VegasModeCoordinator:
fps_log_interval = 5.0 # Log FPS every 5 seconds
last_fps_log_time = start_time
fps_frame_count = 0
# Stutter is invisible in a mean. At 120fps a 5s window covers ~600
# frames, so a 200ms freeze -- plainly visible on a marquee -- moves
# the average from 120.0 to 115.4 and reads as healthy. What a viewer
# notices is the worst frame, so track that separately.
frame_worst = 0.0
frame_hitches = 0
frame_times: List[float] = []
frame_typical = 0.0
# One per coordinator, not per iteration -- run_iteration is called
# repeatedly, so building one here would leak a thread each time.
if _STALL_DUMP_SECONDS > 0 and self._stall_watchdog is None:
self._stall_watchdog = _StallWatchdog(_STALL_DUMP_SECONDS)
watchdog = self._stall_watchdog
logger.info("Starting Vegas iteration for %.1fs", duration)
@@ -491,25 +417,6 @@ class VegasModeCoordinator:
frame_elapsed = time.time() - frame_started
time.sleep(max(0.0, frame_interval - frame_elapsed))
# Measured before the sleep, so this is time spent working rather
# than time spent pacing. A frame that overruns the budget is one
# the viewer sees as a jerk in otherwise smooth motion.
if frame_elapsed > frame_worst:
frame_worst = frame_elapsed
# Measured against what frames actually cost here, not against
# the configured target. The target is routinely set above what
# the panel can hold so vsync does the pacing -- against that
# budget every ordinary frame looks like a hitch, which is how
# the first version of this counter reported 250 per window on a
# display that was running perfectly smoothly.
if frame_typical and frame_elapsed > _HITCH_FACTOR * frame_typical:
frame_hitches += 1
if len(frame_times) >= _TYPICAL_SAMPLE:
frame_typical = sorted(frame_times[-_TYPICAL_SAMPLE:])[_TYPICAL_SAMPLE // 2]
frame_times.append(frame_elapsed)
if watchdog:
watchdog.beat()
# Increment frame count and check for interrupt periodically
frame_count += 1
fps_frame_count += 1
@@ -518,22 +425,12 @@ class VegasModeCoordinator:
current_time = time.time()
if current_time - last_fps_log_time >= fps_log_interval:
fps = fps_frame_count / (current_time - last_fps_log_time)
p99 = 0.0
if frame_times:
ordered = sorted(frame_times)
p99 = ordered[min(len(ordered) - 1,
int(len(ordered) * 0.99))]
logger.info(
"Vegas FPS: %.1f (target: %d, frames: %d) "
"p99 %.1fms worst %.1fms hitches %d",
fps, self.vegas_config.target_fps, fps_frame_count,
p99 * 1000.0, frame_worst * 1000.0, frame_hitches
"Vegas FPS: %.1f (target: %d, frames: %d)",
fps, self.vegas_config.target_fps, fps_frame_count
)
last_fps_log_time = current_time
fps_frame_count = 0
frame_worst = 0.0
frame_hitches = 0
frame_times.clear()
if (self._interrupt_check and
frame_count % self._interrupt_check_interval == 0):
+188
View File
@@ -0,0 +1,188 @@
"""Tests that abandoned cache temp files get collected.
DiskCache.set() writes through tempfile.mkstemp and os.replace, removing its
own temp file in a finally. That covers a failed write, but not a process that
dies between the two -- a SIGKILL, a lost restart race, a power cut, all
ordinary on a Pi. Nothing collected what was left behind: the temp names are
".<key>.json.<random>", and the expiry sweep only listed names ending in
.json, so they accumulated for as long as the card had been in service.
Measured on a live rig before this fix: 76 orphans totalling 1,050 MB -- 81%
of the entire cache directory -- the oldest six months old.
The predicate that decides what to delete is tested harder than the sweep
itself, because a false positive here destroys real data.
"""
import os
import time
import pytest
from src.cache.disk_cache import DiskCache, _ORPHAN_TEMP_MAX_AGE_SECONDS
class FakeStrategy:
@staticmethod
def get_data_type_from_key(key):
return 'default'
POLICIES = {'default': 30}
@pytest.fixture
def cache(tmp_path):
return DiskCache(str(tmp_path))
def _age(path, seconds):
old = time.time() - seconds
os.utime(path, (old, old))
def _write(tmp_path, name, body='{}'):
p = tmp_path / name
p.write_text(body, encoding='utf-8')
return p
class TestWhatCountsAsAnOrphan:
@pytest.mark.parametrize('name', [
'.weather.json.a1b2c3d4',
'.odds_espn_football_nfl_401.json.xyz00000',
'.a.json.b',
])
def test_our_temp_files_are_orphans(self, name):
assert DiskCache._is_orphaned_temp(name)
@pytest.mark.parametrize('name', [
'weather.json', # real data
'.weather.json', # a dotted key that completed
'.gitignore', # not ours
'.hidden', # not ours
'weather.json.bak', # no leading dot: someone else's
'.json.abc', # no key between the dot and .json.
'.weather.json.', # no random component
'notes.txt',
])
def test_everything_else_is_left_alone(self, name):
assert not DiskCache._is_orphaned_temp(name)
def test_the_names_set_actually_creates_are_matched(self, cache, tmp_path):
"""Guard against the predicate and the writer drifting apart."""
created = []
real = os.replace
def capture(src, dst):
created.append(os.path.basename(src))
return real(src, dst)
import src.cache.disk_cache as mod
mod.os.replace = capture
try:
cache.set('weather', {'v': 1})
finally:
mod.os.replace = real
assert created, "set() did not go through the temp-file path"
assert all(DiskCache._is_orphaned_temp(n) for n in created), created
class TestTheSweep:
def test_an_old_orphan_is_removed(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4', 'x' * 5000)
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not p.exists()
assert stats['orphan_temp_files_deleted'] == 1
assert stats['space_freed_bytes'] >= 5000
def test_an_in_flight_write_is_not_snatched_away(self, cache, tmp_path):
# The whole risk of this sweep: deleting a temp file another thread is
# about to os.replace into place.
p = _write(tmp_path, '.weather.json.inflight')
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert p.exists()
def test_real_cache_files_survive(self, cache, tmp_path):
fresh = _write(tmp_path, 'weather.json')
dotted = _write(tmp_path, '.weather.json')
_age(dotted, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert fresh.exists()
assert dotted.exists(), "a completed .json was treated as a temp file"
def test_unrelated_dotfiles_survive(self, cache, tmp_path):
keep = _write(tmp_path, '.gitignore')
_age(keep, 400 * 86400)
cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert keep.exists()
def test_expiry_still_works_alongside_it(self, cache, tmp_path):
stale = _write(tmp_path, 'old.json')
_age(stale, 40 * 86400) # past the 30-day default
orphan = _write(tmp_path, '.old.json.zz999999')
_age(orphan, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert not stale.exists()
assert not orphan.exists()
assert stats['files_deleted'] == 2
assert stats['orphan_temp_files_deleted'] == 1
def test_the_rig_scenario(self, cache, tmp_path):
"""76 orphans of assorted ages, none of them reachable before."""
for i in range(76):
p = _write(tmp_path, '.sched_%d.json.r%06d' % (i, i), 'x' * 1000)
_age(p, (i + 2) * 86400)
keep = _write(tmp_path, 'sched.json')
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['orphan_temp_files_deleted'] == 76
assert keep.exists()
assert not list(tmp_path.glob('.sched_*'))
# The summary line is "<deleted>/<scanned>", so an orphan that is
# deleted but never counted as scanned renders as "76/1".
assert stats['files_scanned'] == 77
assert stats['files_deleted'] <= stats['files_scanned']
def test_deleted_never_exceeds_scanned(self, cache, tmp_path):
p = _write(tmp_path, '.only.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
assert stats['files_deleted'] == 1
assert stats['files_scanned'] == 1
def test_a_missing_file_mid_sweep_is_not_an_error(self, cache, tmp_path):
p = _write(tmp_path, '.weather.json.a1b2c3d4')
_age(p, _ORPHAN_TEMP_MAX_AGE_SECONDS + 60)
import src.cache.disk_cache as mod
real = mod.os.path.getsize
def vanish(path):
if path.endswith('.a1b2c3d4'):
os.remove(path)
raise FileNotFoundError(path)
return real(path)
mod.os.path.getsize = vanish
try:
stats = cache.cleanup_expired_files(FakeStrategy(), POLICIES)
finally:
mod.os.path.getsize = real
assert stats['errors'] == 0
-119
View File
@@ -1,119 +0,0 @@
"""Tests the watchdog that catches a stalled render loop in the act.
Frame-time statistics can say a stall happened but not what caused it, and by
the time the numbers reach the log the stack is long gone. On a live rig the
Vegas loop showed a 3.2s freeze roughly twice an hour with every other frame
under 25ms -- invisible in the mean, and unattributable from the log alone.
This watchdog samples every thread's stack while the loop is still wedged,
which is how that freeze was traced to a plugin generating a 17,000px scroll
image, logo PNG decode and all, on the render thread.
"""
import threading
import time
import pytest
from src.vegas_mode.coordinator import _StallWatchdog
@pytest.fixture
def watchdog():
made = []
def build(threshold):
w = _StallWatchdog(threshold)
made.append(w)
return w
yield build
for w in made:
w.stop()
for w in made:
w._thread.join(timeout=2.0)
assert not w._thread.is_alive(), "watchdog thread outlived its owner"
def _dumps(caplog):
return [r for r in caplog.records if 'render loop stalled' in r.getMessage()]
class TestItFiresOnlyWhenStalled:
def test_a_beating_loop_is_never_reported(self, watchdog, caplog):
w = watchdog(0.2)
deadline = time.time() + 0.9
while time.time() < deadline:
w.beat()
time.sleep(0.02)
assert not _dumps(caplog)
def test_a_stalled_loop_is_reported(self, watchdog, caplog):
w = watchdog(0.2)
w.beat()
time.sleep(0.9)
assert _dumps(caplog), "no stall dump for a loop that stopped beating"
def test_one_dump_per_stall_not_per_poll(self, watchdog, caplog):
# The watchdog polls at threshold/4, so a stall lasting many poll
# intervals must not flood the log with a dump each time.
w = watchdog(0.2)
w.beat()
time.sleep(1.2)
assert len(_dumps(caplog)) == 1, (
"%d dumps for one stall" % len(_dumps(caplog)))
def test_a_later_stall_is_reported_again(self, watchdog, caplog):
w = watchdog(0.2)
w.beat()
time.sleep(0.6)
first = len(_dumps(caplog))
w.beat() # recovered
time.sleep(0.6) # then stalled again
assert len(_dumps(caplog)) == first + 1
class TestWhatItReports:
def test_the_dump_names_threads_and_shows_frames(self, watchdog, caplog):
started = threading.Event()
release = threading.Event()
def parked():
started.set()
release.wait(3.0)
t = threading.Thread(target=parked, name="CulpritThread", daemon=True)
t.start()
started.wait(2.0)
try:
w = watchdog(0.2)
w.beat()
time.sleep(0.7)
dumps = _dumps(caplog)
assert dumps
text = dumps[0].getMessage()
assert "CulpritThread" in text, text
assert " in " in text, "no frames in the dump"
assert ".py:" in text, "no file:line in the dump"
finally:
release.set()
t.join(timeout=2.0)
def test_it_reports_how_long_the_stall_ran(self, watchdog, caplog):
w = watchdog(0.2)
w.beat()
time.sleep(0.8)
text = _dumps(caplog)[0].getMessage()
assert "stalled" in text
# Long enough to have tripped, and not an absurd value.
stalled = float(text.split("stalled")[1].split("s")[0])
assert 0.2 <= stalled <= 3.0, stalled
class TestItIsCheapWhenIdle:
def test_stop_is_prompt(self, caplog):
w = _StallWatchdog(4.0) # long threshold, long poll interval
t0 = time.time()
w.stop()
w._thread.join(timeout=3.0)
assert not w._thread.is_alive(), "stop() did not end the thread"
assert time.time() - t0 < 2.0, "stop() waited out the poll interval"