mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-12 06:08:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d865d41b | ||
|
|
e03fbfe7b1 |
Vendored
+64
-1
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user