mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-17 20:39:13 +00:00
fix(scroll,cache): resolve CodeRabbit review on #523
Eight findings, all reproduced before fixing.
scroll_config.configure() read the refresh rate *after* resolve() had
already used it. resolve() fills in target_fps, pixels_per_frame and the
judder warning from that rate, so on a 60Hz panel every one of them
described 100Hz -- and with snap_to_crisp=False nothing downstream
corrected it, so set_target_fps() paced the helper to 100 FPS. The rate
is now settled first, and falls back to the global config rather than
straight to the default.
refresh_hz_from_config() used `(cfg.get("display") or {}).get(...)`,
which raises AttributeError when either level is truthy but not a
mapping -- out of a function whose whole contract is a rate or a default.
The frame-stats line reported the upper-middle sample as the median and
the 96th sorted sample as p95 of 100. Both are also thresholds (stalls
at 1.5x the median, skips at 0.5x), so the counts were biased too. The
arithmetic is now in frame_stats()/format_frame_stats(), testable
without a clock.
configure()'s docstring and docs/SCROLL_PERFORMANCE.md still said it
applies the frame hold and warns when it cannot. It deliberately does
neither since "tie the frame hold to the scroll, not the plugin"; a
caller following the old text would omit set_scrolling_state() and slow
snapped speeds would still present every refresh.
disk_cache had no policy for non-finite floats: orjson writes null,
the stdlib writes NaN/Infinity, and orjson then rejects those legacy
files so DiskCache.get deleted them as corrupt. One behaviour on both
paths now -- write null, keep legacy records readable. allow_nan=False
detects the values; the replacement walk runs only when there is one,
so the ordinary write path is byte-identical and pays nothing.
build_rgbmatrix_nogil.sh picked the build artifact with a glob piped to
`head -1`, which sorts cpython-311 ahead of cpython-313, so a stale .so
staged in from the source tree was installed as core.so while the GIL
check -- which reads the generated core.cpp, not the .so -- still passed.
It now requires the current interpreter's exact ABI name and fails
closed. Its systemctl calls were also unchecked under `set -uo pipefail`:
a failed stop left the old service running, the following start
succeeded as a no-op, and the health check reported SUCCESS for a
binding that was never loaded.
orjson floor raised to 3.11.6 for CVE-2025-67221 (unbounded recursion
in dumps); it covers the project's Python 3.10-3.13 range.
Adds test/test_cache_nonfinite_floats.py (14) plus regression tests in
test_scroll_config.py and test_scroll_helper.py. 9 of the cache tests
and 9 of the scroll_config tests fail against the pre-fix code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
This commit is contained in:
co-authored by
Claude Opus 5
parent
2622c15265
commit
571cc6fdac
@@ -83,21 +83,29 @@ second refresh, instead of half a pixel every refresh (which has no good
|
|||||||
rendering, only a choice between blur and judder).
|
rendering, only a choice between blur and judder).
|
||||||
|
|
||||||
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
||||||
the ladder and applies the hold, provided it is given the display manager:
|
the ladder and reports the hold that speed needs. It does **not** apply the
|
||||||
|
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
|
||||||
|
share one display manager -- one set at construction is reset the moment any
|
||||||
|
other plugin finishes scrolling. Apply it yourself when the scroll starts:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
scroll_config.configure(
|
settings = scroll_config.configure(
|
||||||
self.scroll_helper,
|
self.scroll_helper,
|
||||||
plugin_config=self.config,
|
plugin_config=self.config,
|
||||||
global_config=self.global_config,
|
global_config=self.global_config,
|
||||||
display_manager=self.display_manager, # required for the hold to apply
|
display_manager=self.display_manager, # supplies the panel refresh rate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ...then, each time this plugin begins scrolling:
|
||||||
|
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `display_manager` a sub-refresh speed still resolves, but the hold is
|
Passing `display_manager` only lets `configure` read the true refresh rate from
|
||||||
never applied and the motion falls back to fractional pixels -- so `configure`
|
`display.hardware`, which a plugin config cannot see. Skipping the
|
||||||
logs a warning rather than failing quietly. Pass `snap_to_crisp=False` to keep
|
`set_scrolling_state` call is the mistake that matters: the speed still
|
||||||
an exact requested speed and accept the artefacts.
|
resolves, but the panel keeps presenting a new frame every refresh, so a slow
|
||||||
|
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
|
||||||
|
keep an exact requested speed and accept the artefacts.
|
||||||
|
|
||||||
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
||||||
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
||||||
|
|||||||
+5
-1
@@ -61,7 +61,11 @@ packaging>=23.0,<27.0
|
|||||||
# matters because that work holds the GIL and stalls
|
# matters because that work holds the GIL and stalls
|
||||||
# the render thread mid-scroll. Falls back to the
|
# the render thread mid-scroll. Falls back to the
|
||||||
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
||||||
# pip install 'orjson>=3.9,<4.0'
|
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
|
||||||
|
# limit recursion on deeply nested documents, and the disk
|
||||||
|
# cache encodes payloads parsed straight from third-party
|
||||||
|
# APIs. 3.11.6 covers the Python range above.
|
||||||
|
# pip install 'orjson>=3.11.6,<4.0'
|
||||||
#
|
#
|
||||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
# (accidental-abuse protection, not security). The
|
# (accidental-abuse protection, not security). The
|
||||||
|
|||||||
@@ -57,22 +57,64 @@ PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
|||||||
|
|
||||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
|
||||||
|
# systemctl failure is silently ignored. That matters most for `stop`: leaving
|
||||||
|
# the old service running means cp overwrites a module the running process has
|
||||||
|
# mapped, the following `start` succeeds as a no-op, and the health check sees
|
||||||
|
# an active unit and reports SUCCESS for a binding that was never loaded.
|
||||||
|
# A machine with no ledmatrix.service at all is a normal build host, so that
|
||||||
|
# case is skipped rather than treated as a failure.
|
||||||
|
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
service_do() {
|
||||||
|
local verb="$1"
|
||||||
|
if ! service_present; then
|
||||||
|
echo " (no ledmatrix.service installed - skipping $verb)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
|
||||||
|
}
|
||||||
|
|
||||||
py_site() {
|
py_site() {
|
||||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The extension filename the interpreter that builds -- and then loads -- this
|
||||||
|
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
|
||||||
|
# venv is made with --system-site-packages from python3, so the two agree;
|
||||||
|
# falling back keeps --install working when the venv has been cleaned up.
|
||||||
|
abi_name() {
|
||||||
|
local py="$VENV/bin/python"
|
||||||
|
[ -x "$py" ] || py=python3
|
||||||
|
"$py" -c \
|
||||||
|
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
|
||||||
|
2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exactly the current interpreter's artifact, never merely the first one that
|
||||||
|
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
|
||||||
|
# source tree by an earlier build comes along for the ride; build_ext --inplace
|
||||||
|
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
|
||||||
|
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
|
||||||
|
# module as core.so while the GIL check below -- which reads the freshly
|
||||||
|
# generated core.cpp, not the .so -- still reported success.
|
||||||
abi_so() {
|
abi_so() {
|
||||||
ls "$BUILD_DIR"/bindings/python/rgbmatrix/core.cpython-*.so 2>/dev/null | head -1
|
local name path
|
||||||
|
name="$(abi_name)" || return 1
|
||||||
|
[ -n "$name" ] || return 1
|
||||||
|
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
|
||||||
|
[ -f "$path" ] || return 1
|
||||||
|
printf '%s\n' "$path"
|
||||||
}
|
}
|
||||||
|
|
||||||
do_rollback() {
|
do_rollback() {
|
||||||
local dst; dst="$(py_site)"
|
local dst; dst="$(py_site)"
|
||||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
||||||
systemctl stop ledmatrix 2>/dev/null
|
service_do stop
|
||||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||||
systemctl start ledmatrix 2>/dev/null
|
service_do start
|
||||||
echo "rolled back to the original core.so"
|
echo "rolled back to the original core.so"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -89,10 +131,10 @@ do_install() {
|
|||||||
echo "backup already present at $BACKUP (keeping the true original)"
|
echo "backup already present at $BACKUP (keeping the true original)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl stop ledmatrix 2>/dev/null
|
service_do stop
|
||||||
cp "$so" "$dst/core.so" || die "install failed"
|
cp "$so" "$dst/core.so" || die "install failed"
|
||||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||||
systemctl start ledmatrix 2>/dev/null
|
service_do start
|
||||||
|
|
||||||
echo "waiting 25s for the display to come back..."
|
echo "waiting 25s for the display to come back..."
|
||||||
sleep 25
|
sleep 25
|
||||||
@@ -106,8 +148,12 @@ do_install() {
|
|||||||
echo "SUCCESS - running on the rebuilt binding"
|
echo "SUCCESS - running on the rebuilt binding"
|
||||||
else
|
else
|
||||||
echo "UNHEALTHY - rolling back"
|
echo "UNHEALTHY - rolling back"
|
||||||
cp -a "$BACKUP" "$dst/core.so"
|
cp -a "$BACKUP" "$dst/core.so" \
|
||||||
systemctl restart ledmatrix
|
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
|
||||||
|
if service_present && ! systemctl restart ledmatrix; then
|
||||||
|
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
|
||||||
|
"down; restore manually with 'sudo bash $0 --rollback'" >&2
|
||||||
|
fi
|
||||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -129,6 +175,12 @@ echo "==> staging a scratch copy at $BUILD_DIR"
|
|||||||
rm -rf "$BUILD_DIR"
|
rm -rf "$BUILD_DIR"
|
||||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
||||||
|
|
||||||
|
# Drop any extension artifacts that came across from the source tree. Nothing
|
||||||
|
# downstream should be able to pick one up, and build_ext --inplace can decide
|
||||||
|
# a copied .so is already up to date and skip the compile entirely.
|
||||||
|
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
|
||||||
|
-name 'core*.so' -delete 2>/dev/null
|
||||||
|
|
||||||
echo "==> patching the bindings to release the GIL"
|
echo "==> patching the bindings to release the GIL"
|
||||||
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
||||||
import io
|
import io
|
||||||
@@ -260,7 +312,8 @@ echo "==> compiling the extension"
|
|||||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
||||||
>/dev/null 2>&1 || die "extension build failed"
|
>/dev/null 2>&1 || die "extension build failed"
|
||||||
|
|
||||||
SO="$(abi_so)"; [ -n "$SO" ] || die "no .so produced"
|
SO="$(abi_so)" || true
|
||||||
|
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
|
||||||
|
|
||||||
# Verify the GIL really is released before anyone installs this.
|
# Verify the GIL really is released before anyone installs this.
|
||||||
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
||||||
|
|||||||
Vendored
+47
-1
@@ -5,6 +5,7 @@ Handles persistent disk-based caching with atomic writes and error recovery.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -62,6 +63,38 @@ def _datetime_default(obj: Any) -> Any:
|
|||||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_nonfinite(obj: Any) -> Any:
|
||||||
|
"""Non-finite floats -> None, matching what ``orjson.dumps`` writes.
|
||||||
|
|
||||||
|
Only reached once a strict pass has proved there is something to replace,
|
||||||
|
so the ordinary write path never pays for this walk.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, float):
|
||||||
|
return obj if math.isfinite(obj) else None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _replace_nonfinite(v) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_replace_nonfinite(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# NON-FINITE FLOATS
|
||||||
|
# -----------------
|
||||||
|
# JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
||||||
|
# orjson refuses to and writes null. That divergence is not acceptable in a
|
||||||
|
# cache whose files outlive the decision of which encoder is installed, so the
|
||||||
|
# policy here is one behaviour on both paths:
|
||||||
|
#
|
||||||
|
# writing non-finite floats become null, whichever encoder is in use
|
||||||
|
# reading files already on disk that carry the stdlib's NaN/Infinity
|
||||||
|
# tokens stay readable, whichever encoder is in use
|
||||||
|
#
|
||||||
|
# Without the write half, installing orjson silently changed cached values.
|
||||||
|
# Without the read half, installing orjson turned every legacy record holding a
|
||||||
|
# NaN into a "corrupted cache file" that DiskCache.get logged as an error and
|
||||||
|
# deleted. Both halves are covered by test/test_cache_nonfinite_floats.py.
|
||||||
|
|
||||||
|
|
||||||
if orjson is not None:
|
if orjson is not None:
|
||||||
# Encoding the cache record dominated the background fetch worker: on a
|
# Encoding the cache record dominated the background fetch worker: on a
|
||||||
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
# Pi 4, stdlib json.dumps runs ~12ms per MB and holds the GIL for all of
|
||||||
@@ -82,10 +115,23 @@ if orjson is not None:
|
|||||||
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
return orjson.dumps(data, default=_datetime_default, option=_DUMPS_OPTS)
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
def _loads(raw: bytes) -> Any:
|
||||||
|
try:
|
||||||
return orjson.loads(raw)
|
return orjson.loads(raw)
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
# Legacy record written by the stdlib path, carrying NaN or
|
||||||
|
# Infinity. Genuinely malformed files raise again from here, as
|
||||||
|
# json.JSONDecodeError, which is what DiskCache.get expects.
|
||||||
|
return json.loads(raw)
|
||||||
else:
|
else:
|
||||||
def _dumps(data: Any) -> bytes:
|
def _dumps(data: Any) -> bytes:
|
||||||
return json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
|
try:
|
||||||
|
return json.dumps(data, cls=DateTimeEncoder,
|
||||||
|
allow_nan=False).encode("utf-8")
|
||||||
|
except ValueError:
|
||||||
|
# allow_nan=False is what detects the non-finite values; the walk
|
||||||
|
# runs only now that we know there is one to replace.
|
||||||
|
return json.dumps(_replace_nonfinite(data), cls=DateTimeEncoder,
|
||||||
|
allow_nan=False).encode("utf-8")
|
||||||
|
|
||||||
def _loads(raw: bytes) -> Any:
|
def _loads(raw: bytes) -> Any:
|
||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
|
|||||||
+34
-15
@@ -331,11 +331,13 @@ def configure(
|
|||||||
stepping is not used. ``hasattr`` guards keep this usable against older
|
stepping is not used. ``hasattr`` guards keep this usable against older
|
||||||
ScrollHelper builds that a plugin may be running on.
|
ScrollHelper builds that a plugin may be running on.
|
||||||
|
|
||||||
:param display_manager: when given, the frame hold for the chosen speed is
|
:param display_manager: consulted for the panel's refresh rate only (it can
|
||||||
applied to it. Without this a sub-refresh speed still resolves, but the
|
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
||||||
panel keeps presenting a new frame every refresh, so the motion falls
|
here -- see the note in the body. The caller must pass
|
||||||
back to fractional pixels and judders -- the hold is what makes slow
|
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
||||||
speeds crisp.
|
...)`` when it starts scrolling, or a sub-refresh speed still presents
|
||||||
|
a new frame every refresh and the motion falls back to fractional
|
||||||
|
pixels.
|
||||||
:param snap_to_crisp: move the requested speed to the nearest speed the
|
:param snap_to_crisp: move the requested speed to the nearest speed the
|
||||||
panel can show in whole pixels. On by default because a speed that does
|
panel can show in whole pixels. On by default because a speed that does
|
||||||
not divide evenly has no good rendering, only a choice of artefacts.
|
not divide evenly has no good rendering, only a choice of artefacts.
|
||||||
@@ -343,20 +345,28 @@ def configure(
|
|||||||
:returns: the settings applied, so the caller can log or assert on them.
|
:returns: the settings applied, so the caller can log or assert on them.
|
||||||
"""
|
"""
|
||||||
log = plugin_logger or logger
|
log = plugin_logger or logger
|
||||||
|
|
||||||
|
# Refresh rate, most authoritative first: what the caller passed, then the
|
||||||
|
# display manager (which can see display.hardware; a plugin cannot), then
|
||||||
|
# the global config, then the default.
|
||||||
|
#
|
||||||
|
# This has to be settled BEFORE resolve(), not after. resolve() uses the
|
||||||
|
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
||||||
|
# so deriving it afterwards described a 100Hz panel to everyone running at
|
||||||
|
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
||||||
|
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
|
||||||
|
hz = _coerce(refresh_hz)
|
||||||
|
if hz is None and display_manager is not None:
|
||||||
|
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
||||||
|
if hz is None:
|
||||||
|
hz = refresh_hz_from_config(global_config)
|
||||||
|
|
||||||
settings = resolve(
|
settings = resolve(
|
||||||
plugin_config,
|
plugin_config,
|
||||||
global_config,
|
global_config,
|
||||||
default_pixels_per_second=default_pixels_per_second,
|
default_pixels_per_second=default_pixels_per_second,
|
||||||
refresh_hz=refresh_hz,
|
refresh_hz=hz,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh rate, most authoritative first: what the caller passed, then the
|
|
||||||
# display manager (which can see display.hardware; a plugin cannot), then
|
|
||||||
# whatever resolve() inferred, then the default.
|
|
||||||
hz = _coerce(refresh_hz)
|
|
||||||
if hz is None and display_manager is not None:
|
|
||||||
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
|
||||||
hz = hz or settings.target_fps or DEFAULT_REFRESH_HZ
|
|
||||||
applied = settings.pixels_per_second
|
applied = settings.pixels_per_second
|
||||||
choice = None
|
choice = None
|
||||||
|
|
||||||
@@ -417,5 +427,14 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
|||||||
"""The panel's refresh cap from the global config, or the default."""
|
"""The panel's refresh cap from the global config, or the default."""
|
||||||
if not isinstance(global_config, dict):
|
if not isinstance(global_config, dict):
|
||||||
return DEFAULT_REFRESH_HZ
|
return DEFAULT_REFRESH_HZ
|
||||||
hardware = (global_config.get("display") or {}).get("hardware") or {}
|
# Each level is checked for being a mapping rather than merely truthy: a
|
||||||
|
# malformed config where display or display.hardware is a string or a list
|
||||||
|
# raised AttributeError out of what is meant to be a total function with a
|
||||||
|
# default, taking down every caller that asked for the refresh rate.
|
||||||
|
display = global_config.get("display")
|
||||||
|
if not isinstance(display, dict):
|
||||||
|
return DEFAULT_REFRESH_HZ
|
||||||
|
hardware = display.get("hardware")
|
||||||
|
if not isinstance(hardware, dict):
|
||||||
|
return DEFAULT_REFRESH_HZ
|
||||||
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
||||||
|
|||||||
+46
-19
@@ -16,6 +16,7 @@ Features:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -29,6 +30,49 @@ except ImportError:
|
|||||||
HAS_SCIPY = False
|
HAS_SCIPY = False
|
||||||
|
|
||||||
|
|
||||||
|
def frame_stats(frame_times: list) -> Dict[str, Any]:
|
||||||
|
"""Summary statistics over one window of frame durations (seconds).
|
||||||
|
|
||||||
|
Split out of log_frame_rate() so the arithmetic can be tested without a
|
||||||
|
clock. Median and p95 are the real ones: the median takes both middle
|
||||||
|
samples on an even window, and p95 is nearest-rank, so a 100-frame window
|
||||||
|
reports the 95th sorted sample rather than the 96th. That matters twice
|
||||||
|
over, because the median is also the threshold the stall and skip counts
|
||||||
|
are measured against.
|
||||||
|
"""
|
||||||
|
window = sorted(frame_times)
|
||||||
|
n = len(window)
|
||||||
|
median = (window[n // 2] if n % 2
|
||||||
|
else (window[n // 2 - 1] + window[n // 2]) / 2.0)
|
||||||
|
mean = sum(window) / n
|
||||||
|
# Anything past 1.5x the median missed a panel refresh; anything under
|
||||||
|
# half of it never reached the panel at all (dirty tracking skipped the
|
||||||
|
# swap, so the frame did not wait for vsync).
|
||||||
|
return {
|
||||||
|
"frames": n,
|
||||||
|
"fps": (1.0 / mean) if mean > 0 else 0.0,
|
||||||
|
"median": median,
|
||||||
|
"p95": window[max(0, math.ceil(0.95 * n) - 1)],
|
||||||
|
"max": window[-1],
|
||||||
|
"min": window[0],
|
||||||
|
"stalls": sum(1 for f in window if f > median * 1.5),
|
||||||
|
"skips": sum(1 for f in window if f < median * 0.5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_frame_stats(frame_times: list) -> str:
|
||||||
|
"""The one-line rendering of frame_stats(), in milliseconds."""
|
||||||
|
s = frame_stats(frame_times)
|
||||||
|
n = s["frames"]
|
||||||
|
return (
|
||||||
|
f"{s['fps']:.1f} fps over {n} frames | "
|
||||||
|
f"median {s['median'] * 1000:.2f}ms p95 {s['p95'] * 1000:.2f}ms "
|
||||||
|
f"max {s['max'] * 1000:.2f}ms min {s['min'] * 1000:.2f}ms | "
|
||||||
|
f"stalls {s['stalls']} ({100.0 * s['stalls'] / n:.1f}%) "
|
||||||
|
f"skips {s['skips']} ({100.0 * s['skips'] / n:.1f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScrollHelper:
|
class ScrollHelper:
|
||||||
"""
|
"""
|
||||||
Helper class for scrolling text and image content on LED displays.
|
Helper class for scrolling text and image content on LED displays.
|
||||||
@@ -1055,26 +1099,9 @@ class ScrollHelper:
|
|||||||
|
|
||||||
# Log FPS every 5 seconds to avoid spam
|
# Log FPS every 5 seconds to avoid spam
|
||||||
if current_time - self.last_fps_log_time >= 5.0:
|
if current_time - self.last_fps_log_time >= 5.0:
|
||||||
window = sorted(self._window) if self._window else [frame_time]
|
|
||||||
n = len(window)
|
|
||||||
median = window[n // 2]
|
|
||||||
p95 = window[min(n - 1, int(n * 0.95))]
|
|
||||||
worst = window[-1]
|
|
||||||
best = window[0]
|
|
||||||
mean = sum(window) / n
|
|
||||||
# Anything past 1.5x the median missed a panel refresh; anything
|
|
||||||
# under half of it never reached the panel at all (dirty tracking
|
|
||||||
# skipped the swap, so the frame did not wait for vsync).
|
|
||||||
stalls = sum(1 for f in window if f > median * 1.5)
|
|
||||||
skips = sum(1 for f in window if f < median * 0.5)
|
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Scroll frame stats - %.1f fps over %d frames | "
|
"Scroll frame stats - %s",
|
||||||
"median %.2fms p95 %.2fms max %.2fms min %.2fms | "
|
format_frame_stats(self._window or [frame_time]),
|
||||||
"stalls %d (%.1f%%) skips %d (%.1f%%)",
|
|
||||||
(1.0 / mean) if mean > 0 else 0.0, n,
|
|
||||||
median * 1000, p95 * 1000, worst * 1000, best * 1000,
|
|
||||||
stalls, 100.0 * stalls / n, skips, 100.0 * skips / n,
|
|
||||||
)
|
)
|
||||||
self.last_fps_log_time = current_time
|
self.last_fps_log_time = current_time
|
||||||
self.frame_count = 0
|
self.frame_count = 0
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Non-finite floats round-trip identically with and without orjson.
|
||||||
|
|
||||||
|
JSON has no NaN or Infinity. The stdlib emits them anyway as an extension;
|
||||||
|
orjson refuses to and writes null. Cache files outlive the decision of which
|
||||||
|
encoder is installed, so both halves of that gap are pinned here:
|
||||||
|
|
||||||
|
* writing -- installing orjson must not silently change what gets cached,
|
||||||
|
so the stdlib path writes null too;
|
||||||
|
* reading -- records already on disk carrying NaN or Infinity must stay
|
||||||
|
readable, or installing orjson turns each of them into a "corrupted cache
|
||||||
|
file" that DiskCache.get logs as an error and deletes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from src.cache import disk_cache as disk_cache_module # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stdlib_cache():
|
||||||
|
"""The module as it loads on a host with no orjson wheel."""
|
||||||
|
# A None entry in sys.modules makes `import orjson` raise ImportError,
|
||||||
|
# which is the branch we want, whether or not orjson is really installed.
|
||||||
|
with mock.patch.dict(sys.modules, {"orjson": None}):
|
||||||
|
module = importlib.reload(disk_cache_module)
|
||||||
|
assert module.orjson is None
|
||||||
|
yield module
|
||||||
|
importlib.reload(disk_cache_module)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def orjson_cache():
|
||||||
|
"""The module as it loads with orjson available."""
|
||||||
|
module = importlib.reload(disk_cache_module)
|
||||||
|
if module.orjson is None:
|
||||||
|
pytest.skip("orjson is not installed on this host")
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
NON_FINITE = {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")}
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_constant(name):
|
||||||
|
"""Make json.loads as strict as orjson about NaN/Infinity tokens."""
|
||||||
|
raise AssertionError(f"non-spec JSON constant in output: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWritePolicy:
|
||||||
|
"""Non-finite floats become null on whichever encoder is in use."""
|
||||||
|
|
||||||
|
def _assert_nulled(self, module):
|
||||||
|
record = module._loads(module._dumps(dict(NON_FINITE, ok=1.5)))
|
||||||
|
assert record["nan"] is None
|
||||||
|
assert record["inf"] is None
|
||||||
|
assert record["ninf"] is None
|
||||||
|
# Finite values are untouched.
|
||||||
|
assert record["ok"] == 1.5
|
||||||
|
|
||||||
|
def test_stdlib_writes_null(self, stdlib_cache):
|
||||||
|
self._assert_nulled(stdlib_cache)
|
||||||
|
|
||||||
|
def test_orjson_writes_null(self, orjson_cache):
|
||||||
|
self._assert_nulled(orjson_cache)
|
||||||
|
|
||||||
|
def test_stdlib_emits_spec_compliant_json(self, stdlib_cache):
|
||||||
|
# The point of the write half: bytes written without orjson must still
|
||||||
|
# parse once orjson is installed later. json.loads accepts the
|
||||||
|
# extension tokens, so it cannot show this -- assert on the bytes, and
|
||||||
|
# on a strict reader when there is one.
|
||||||
|
raw = stdlib_cache._dumps(dict(NON_FINITE))
|
||||||
|
assert b"NaN" not in raw
|
||||||
|
assert b"Infinity" not in raw
|
||||||
|
assert json.loads(raw, parse_constant=_reject_constant) == {
|
||||||
|
"nan": None, "inf": None, "ninf": None}
|
||||||
|
|
||||||
|
def test_nested_non_finite_are_replaced(self, stdlib_cache):
|
||||||
|
data = {"a": [1.0, float("nan"), {"b": float("inf")}], "c": (float("-inf"),)}
|
||||||
|
record = stdlib_cache._loads(stdlib_cache._dumps(data))
|
||||||
|
assert record["a"] == [1.0, None, {"b": None}]
|
||||||
|
assert record["c"] == [None]
|
||||||
|
|
||||||
|
def test_finite_payloads_are_byte_identical_to_the_old_encoder(self, stdlib_cache):
|
||||||
|
# allow_nan=False must not change ordinary output.
|
||||||
|
data = {"x": 1, "y": [1.5, "s", True, None], "z": {"k": 2.25}}
|
||||||
|
assert stdlib_cache._dumps(data) == json.dumps(
|
||||||
|
data, cls=stdlib_cache.DateTimeEncoder).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReplaceNonFinite:
|
||||||
|
def test_leaves_ordinary_values_alone(self):
|
||||||
|
for value in (1, 1.5, "s", True, None, [], {}):
|
||||||
|
assert disk_cache_module._replace_nonfinite(value) == value
|
||||||
|
|
||||||
|
def test_replaces_every_non_finite_float(self):
|
||||||
|
for value in NON_FINITE.values():
|
||||||
|
assert disk_cache_module._replace_nonfinite(value) is None
|
||||||
|
|
||||||
|
def test_walks_nested_containers(self):
|
||||||
|
assert disk_cache_module._replace_nonfinite(
|
||||||
|
{"a": [{"b": float("nan")}]}) == {"a": [{"b": None}]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyRecordsStayReadable:
|
||||||
|
"""Files written before orjson arrived still load."""
|
||||||
|
|
||||||
|
LEGACY = b'{"timestamp": 1000.0, "value": NaN, "other": Infinity}'
|
||||||
|
|
||||||
|
def test_stdlib_reads_legacy_tokens(self, stdlib_cache):
|
||||||
|
record = stdlib_cache._loads(self.LEGACY)
|
||||||
|
assert math.isnan(record["value"])
|
||||||
|
assert math.isinf(record["other"])
|
||||||
|
|
||||||
|
def test_orjson_falls_back_for_legacy_tokens(self, orjson_cache):
|
||||||
|
record = orjson_cache._loads(self.LEGACY)
|
||||||
|
assert math.isnan(record["value"])
|
||||||
|
assert math.isinf(record["other"])
|
||||||
|
|
||||||
|
def test_genuinely_malformed_files_still_raise(self, stdlib_cache):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
stdlib_cache._loads(b'{"a": ')
|
||||||
|
|
||||||
|
def test_orjson_still_raises_for_malformed_files(self, orjson_cache):
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
orjson_cache._loads(b'{"a": ')
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiskCacheEndToEnd:
|
||||||
|
def _cache(self, module, tmp_path):
|
||||||
|
return module.DiskCache(str(tmp_path))
|
||||||
|
|
||||||
|
def test_legacy_file_is_not_deleted_as_corrupt(self, orjson_cache, tmp_path):
|
||||||
|
cache = self._cache(orjson_cache, tmp_path)
|
||||||
|
path = cache.get_cache_path("legacy")
|
||||||
|
Path(path).write_bytes(
|
||||||
|
b'{"timestamp": %d, "value": NaN}' % int(__import__("time").time()))
|
||||||
|
|
||||||
|
record = cache.get("legacy", max_age=None)
|
||||||
|
|
||||||
|
assert record is not None, "legacy NaN record was treated as corrupt"
|
||||||
|
assert math.isnan(record["value"])
|
||||||
|
assert Path(path).exists(), "legacy NaN record was deleted"
|
||||||
|
|
||||||
|
def test_round_trip_through_set_and_get(self, stdlib_cache, tmp_path):
|
||||||
|
cache = self._cache(stdlib_cache, tmp_path)
|
||||||
|
cache.set("k", {"timestamp": __import__("time").time(),
|
||||||
|
"value": float("nan")})
|
||||||
|
record = cache.get("k", max_age=None)
|
||||||
|
assert record is not None
|
||||||
|
assert record["value"] is None
|
||||||
@@ -275,6 +275,20 @@ class TestRefreshFromConfig:
|
|||||||
def test_falls_back_to_the_default(self, cfg):
|
def test_falls_back_to_the_default(self, cfg):
|
||||||
assert refresh_hz_from_config(cfg) == 100.0
|
assert refresh_hz_from_config(cfg) == 100.0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cfg", [
|
||||||
|
{"display": "nonsense"},
|
||||||
|
{"display": ["nonsense"]},
|
||||||
|
{"display": 60},
|
||||||
|
{"display": {"hardware": "nonsense"}},
|
||||||
|
{"display": {"hardware": ["nonsense"]}},
|
||||||
|
{"display": {"hardware": 60}},
|
||||||
|
])
|
||||||
|
def test_malformed_nesting_falls_back_rather_than_raising(self, cfg):
|
||||||
|
# Truthy-but-not-a-mapping at either level used to reach .get() on a
|
||||||
|
# str/list and raise AttributeError out of a function whose whole
|
||||||
|
# contract is "a refresh rate, or the default".
|
||||||
|
assert refresh_hz_from_config(cfg) == 100.0
|
||||||
|
|
||||||
|
|
||||||
class TestCrispLadder:
|
class TestCrispLadder:
|
||||||
"""Whole-pixel speeds available on a given panel."""
|
"""Whole-pixel speeds available on a given panel."""
|
||||||
@@ -388,3 +402,66 @@ class TestRefreshFromDisplayManager:
|
|||||||
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}},
|
||||||
display_manager=bare)
|
display_manager=bare)
|
||||||
assert s.frame_hold == 2, "assumed 100Hz"
|
assert s.frame_hold == 2, "assumed 100Hz"
|
||||||
|
|
||||||
|
def test_reported_settings_describe_the_display_manager_rate(self):
|
||||||
|
"""Without snapping, nothing downstream corrects a wrong refresh rate.
|
||||||
|
|
||||||
|
The rate used to be read *after* resolve() had already filled in
|
||||||
|
target_fps, pixels_per_frame and the judder warning from the 100Hz
|
||||||
|
default -- so on a 60Hz panel every one of those described 100Hz, and
|
||||||
|
set_target_fps() paced the helper to 100 FPS.
|
||||||
|
"""
|
||||||
|
dm = self.DM(60.0)
|
||||||
|
helper = FakeHelper()
|
||||||
|
s = configure(helper, {"display_options": {"scroll_pixels_per_second": 30.0}},
|
||||||
|
display_manager=dm, snap_to_crisp=False)
|
||||||
|
|
||||||
|
assert s.target_fps == pytest.approx(60.0)
|
||||||
|
assert s.pixels_per_frame == pytest.approx(0.5), "30px/s over 60 frames"
|
||||||
|
assert helper.target_fps == pytest.approx(60.0)
|
||||||
|
|
||||||
|
def test_judder_warning_is_computed_at_the_real_refresh_rate(self):
|
||||||
|
dm = self.DM(60.0)
|
||||||
|
# 60px/s is exactly 1px per refresh at 60Hz -- crisp, no warning. At
|
||||||
|
# the 100Hz default it is 0.6px per refresh and would be flagged.
|
||||||
|
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 60.0}},
|
||||||
|
display_manager=dm, snap_to_crisp=False)
|
||||||
|
assert s.warning is None, s.warning
|
||||||
|
|
||||||
|
def test_global_config_supplies_the_rate_without_a_display_manager(self):
|
||||||
|
s = configure(
|
||||||
|
FakeHelper(),
|
||||||
|
{"display_options": {"scroll_pixels_per_second": 30.0}},
|
||||||
|
{"display": {"hardware": {"limit_refresh_rate_hz": 60}}},
|
||||||
|
snap_to_crisp=False,
|
||||||
|
)
|
||||||
|
assert s.target_fps == pytest.approx(60.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameHoldIsReportedNotApplied:
|
||||||
|
"""configure() reports the hold; the caller applies it when it scrolls.
|
||||||
|
|
||||||
|
The hold belongs to a scroll, not to a plugin's lifetime -- plugins share
|
||||||
|
one display manager, so one set at construction is reset the moment any
|
||||||
|
other plugin stops scrolling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class RecordingDM:
|
||||||
|
refresh_hz = 100.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def set_scrolling_state(self, is_scrolling, frame_hold=1):
|
||||||
|
self.calls.append((is_scrolling, frame_hold))
|
||||||
|
|
||||||
|
def set_frame_hold(self, refreshes):
|
||||||
|
self.calls.append(("set_frame_hold", refreshes))
|
||||||
|
|
||||||
|
def test_configure_does_not_touch_the_display_manager(self):
|
||||||
|
dm = self.RecordingDM()
|
||||||
|
settings = configure(
|
||||||
|
FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
||||||
|
display_manager=dm)
|
||||||
|
assert dm.calls == [], "configure() must not apply the hold itself"
|
||||||
|
assert settings.frame_hold == 4, "but it must report what to apply"
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import time
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
from src.common.scroll_helper import (
|
||||||
|
ScrollHelper,
|
||||||
|
format_frame_stats,
|
||||||
|
frame_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DISPLAY_W = 64
|
DISPLAY_W = 64
|
||||||
@@ -315,3 +319,77 @@ class TestGetScrollInfo:
|
|||||||
helper.scroll_position = 42.0
|
helper.scroll_position = 42.0
|
||||||
info = helper.get_scroll_info()
|
info = helper.get_scroll_info()
|
||||||
assert info["scroll_position"] == 42.0
|
assert info["scroll_position"] == 42.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameStatsPercentiles:
|
||||||
|
"""The stats line is the instrument this whole scroll change is measured
|
||||||
|
with, so its median and p95 have to be the real ones.
|
||||||
|
|
||||||
|
Both are also thresholds: stalls are counted at 1.5x the median and skips
|
||||||
|
at 0.5x, so an off-by-one in the median biases the counts as well as the
|
||||||
|
printed numbers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 100 samples of 1..100ms. True median 50.5ms (the mean of the two middle
|
||||||
|
# samples, not the upper one at 51ms); nearest-rank p95 is the 95th
|
||||||
|
# sample at 95ms, not the 96th at 96ms.
|
||||||
|
HUNDRED = [i / 1000.0 for i in range(1, 101)]
|
||||||
|
|
||||||
|
def test_even_window_median_averages_both_middle_samples(self):
|
||||||
|
assert frame_stats(self.HUNDRED)["median"] == pytest.approx(0.0505)
|
||||||
|
|
||||||
|
def test_p95_uses_nearest_rank(self):
|
||||||
|
assert frame_stats(self.HUNDRED)["p95"] == pytest.approx(0.095)
|
||||||
|
|
||||||
|
def test_odd_window_median_is_the_middle_sample(self):
|
||||||
|
times = [i / 1000.0 for i in range(1, 102)] # 101 samples
|
||||||
|
assert frame_stats(times)["median"] == pytest.approx(0.051)
|
||||||
|
|
||||||
|
def test_single_sample_window_does_not_index_out_of_range(self):
|
||||||
|
stats = frame_stats([0.010])
|
||||||
|
assert stats["median"] == pytest.approx(0.010)
|
||||||
|
assert stats["p95"] == pytest.approx(0.010)
|
||||||
|
assert stats["min"] == stats["max"] == pytest.approx(0.010)
|
||||||
|
|
||||||
|
def test_two_sample_window(self):
|
||||||
|
stats = frame_stats([0.010, 0.020])
|
||||||
|
assert stats["median"] == pytest.approx(0.015)
|
||||||
|
assert stats["p95"] == pytest.approx(0.020)
|
||||||
|
|
||||||
|
def test_input_order_does_not_matter(self):
|
||||||
|
assert frame_stats(list(reversed(self.HUNDRED))) == frame_stats(self.HUNDRED)
|
||||||
|
|
||||||
|
def test_caller_window_is_not_mutated(self):
|
||||||
|
times = [0.030, 0.010, 0.020]
|
||||||
|
frame_stats(times)
|
||||||
|
assert times == [0.030, 0.010, 0.020]
|
||||||
|
|
||||||
|
def test_stall_threshold_follows_the_median(self):
|
||||||
|
# Ten 10ms frames and two 30ms stalls: median 10ms, so >15ms is a
|
||||||
|
# stall -- exactly the two.
|
||||||
|
stats = frame_stats([0.010] * 10 + [0.030] * 2)
|
||||||
|
assert stats["stalls"] == 2
|
||||||
|
assert stats["skips"] == 0
|
||||||
|
|
||||||
|
def test_skips_are_frames_that_never_reached_the_panel(self):
|
||||||
|
stats = frame_stats([0.010] * 10 + [0.002] * 3)
|
||||||
|
assert stats["skips"] == 3
|
||||||
|
assert stats["stalls"] == 0
|
||||||
|
|
||||||
|
def test_fps_is_the_reciprocal_of_the_mean(self):
|
||||||
|
assert frame_stats([0.010] * 50)["fps"] == pytest.approx(100.0)
|
||||||
|
|
||||||
|
def test_formatted_line_reports_the_corrected_values(self):
|
||||||
|
line = format_frame_stats(self.HUNDRED)
|
||||||
|
assert "median 50.50ms" in line, line
|
||||||
|
assert "p95 95.00ms" in line, line
|
||||||
|
assert "over 100 frames" in line, line
|
||||||
|
|
||||||
|
def test_log_frame_rate_emits_the_line_and_clears_the_window(self, helper):
|
||||||
|
helper._window = [0.010] * 20
|
||||||
|
helper.last_fps_log_time = 0.0 # force the 5s boundary
|
||||||
|
with patch.object(helper.logger, "info") as info:
|
||||||
|
helper.log_frame_rate()
|
||||||
|
assert info.called
|
||||||
|
assert "Scroll frame stats" in info.call_args[0][0]
|
||||||
|
assert helper._window == []
|
||||||
|
|||||||
Reference in New Issue
Block a user