diff --git a/hyperwall/__init__.py b/hyperwall/__init__.py index fed34ff..ef63ed9 100644 --- a/hyperwall/__init__.py +++ b/hyperwall/__init__.py @@ -7,7 +7,7 @@ from __future__ import annotations -__version__ = "10.12.0" +__version__ = "10.12.1" # Short "major.minor" form, derived — used for User-Agent / Emby auth Version / # window titles so a version bump touches exactly ONE line (this file). VERSION_SHORT = ".".join(__version__.split(".")[:2]) diff --git a/hyperwall/cell.py b/hyperwall/cell.py index 2d53401..36601dd 100644 --- a/hyperwall/cell.py +++ b/hyperwall/cell.py @@ -228,6 +228,8 @@ def __init__(self, controller: Any): self._freeze_t0 = 0.0 self._freeze_count = 0 self._freeze_total_s = 0.0 + self._freeze_postseek_count = 0 + self._last_seek_ts = 0.0 self._buffering_card = False self._retry_count = 0 self._force_transcode = False @@ -1063,6 +1065,7 @@ def _seek_release(self) -> None: # Restore the PRE-DRAG pause state instead of always # resuming — a paused cell stays paused after a seek. resume_paused = getattr(self, "_paused_before_seek", False) + self._last_seek_ts = _time.monotonic() self._mpv["pause"] = resume_paused self._paused = resume_paused self.btn_play.setText(_G_PLAY if resume_paused else _G_PAUSE) @@ -1238,8 +1241,16 @@ def _handle_buffering(self, gen: int, buffering: bool) -> None: if not self._played_anything: return # startup fill, not a mid-playback freeze if self._freeze_t0 == 0.0: - self._freeze_t0 = _time.monotonic() - self._freeze_count += 1 + now = _time.monotonic() + self._freeze_t0 = now + # A refill right after a seek is expected demuxer behavior, + # not a spontaneous starvation — count it separately so soak + # numbers stop conflating the two (2026-07-14 soak: 43 random + # seeks inflated the freeze count). + if now - self._last_seek_ts < 5.0: + self._freeze_postseek_count += 1 + else: + self._freeze_count += 1 # Replace whatever card is up (stale title / loading): during a # freeze the buffering state is the most relevant thing on the # cell. Parked/error cells never reach here (not playing). @@ -1254,9 +1265,15 @@ def _handle_buffering(self, gen: int, buffering: bool) -> None: state = self._mpv.cache_buffering_state except Exception: state = "?" + tag = ( + "post-seek refill" + if self._last_seek_ts > 0 + and _time.monotonic() - self._last_seek_ts < 5.0 + dur + else "cache starvation" + ) logger.warning( - "FREEZE: %.1fs cache starvation on '%s' " - "(buffering-state=%s)", dur, + "FREEZE: %.1fs %s on '%s' (buffering-state=%s)", + dur, tag, (self.current_item or {}).get("Name", "?"), state, ) if self._buffering_card: diff --git a/hyperwall/perftrace.py b/hyperwall/perftrace.py index 79825f1..cec0ba6 100644 --- a/hyperwall/perftrace.py +++ b/hyperwall/perftrace.py @@ -87,11 +87,20 @@ def deco(fn: Callable) -> Callable: if not PERFTRACE_ENABLED: return fn + # Qt signals append payload args (clicked emits a `checked` bool); + # PyQt6 normally drops extras by inspecting the slot's arity, but a + # bare *args wrapper defeats that inspection and the payload gets + # through — under tracing, EVERY traced click handler crashed with + # TypeError and the state machine drifted (caught live by the soak + # exerciser's invariant checks, 2026-07-14). Cap positional args to + # the wrapped function's true arity. + max_pos = fn.__code__.co_argcount + @functools.wraps(fn) def wrapper(*args: Any, **kwargs: Any) -> Any: t0 = time.perf_counter() try: - return fn(*args, **kwargs) + return fn(*args[:max_pos], **kwargs) finally: dt = (time.perf_counter() - t0) * 1000 if dt > _SLOW_SLOT_MS: diff --git a/hyperwall/wall.py b/hyperwall/wall.py index 27c9d9a..fc041e4 100644 --- a/hyperwall/wall.py +++ b/hyperwall/wall.py @@ -595,6 +595,7 @@ def _dump_stats_json(self) -> None: "info": {k: v for k, v in c._stats_info.items()}, "freezes": c._freeze_count, "freeze_seconds": round(c._freeze_total_s, 1), + "postseek_refills": c._freeze_postseek_count, "last_item": (c.current_item or {}).get("Name"), }) payload = { @@ -627,13 +628,13 @@ def _dump_stats_json(self) -> None: i = s["info"] logger.info( "STATS cell %d drop=%g mistimed=%g vo-delayed=%g " - "dec-drop=%g freezes=%d(%ss) hwdec=%s fps=%s bitrate=%s", + "dec-drop=%g freezes=%d(%ss) postseek=%d hwdec=%s fps=%s bitrate=%s", s["cell"], t.get("frame-drop-count", 0), t.get("mistimed-frame-count", 0), t.get("vo-delayed-frame-count", 0), t.get("decoder-frame-drop-count", 0), - s["freezes"], s["freeze_seconds"], + s["freezes"], s["freeze_seconds"], s["postseek_refills"], i.get("hwdec-current"), i.get("estimated-vf-fps") or i.get("container-fps"), i.get("video-bitrate"), diff --git a/tests/run_repo_guards.py b/tests/run_repo_guards.py index a458421..9f9d90b 100644 --- a/tests/run_repo_guards.py +++ b/tests/run_repo_guards.py @@ -46,10 +46,10 @@ def test_01_entry_point_imports(): def test_02_package_identity(): """Package has version and banner.""" from hyperwall import __version__, runtime_banner - assert __version__ == "10.12.0" + assert __version__ == "10.12.1" banner = runtime_banner() assert "Hyperwall" in banner - assert "10.12.0" in banner + assert "10.12.1" in banner def test_03_config_loads(): diff --git a/tests/test_instrumentation.py b/tests/test_instrumentation.py index e3a5abb..4e5e5d5 100644 --- a/tests/test_instrumentation.py +++ b/tests/test_instrumentation.py @@ -71,6 +71,36 @@ def test_resource_snapshot_returns_real_values(): assert "user" in snap and "gdi" in snap +def test_traced_slot_survives_qt_signal_payload_args(): + """Regression for the 2026-07-14 soak finding: Qt's clicked signal + carries a `checked` bool; the @traced wrapper's *args defeated PyQt's + arity inspection, the bool got through, and EVERY traced click handler + crashed with TypeError whenever tracing was enabled — silently drifting + button state from cached state (caught by the exerciser invariants).""" + from hyperwall import perftrace + from PyQt6.QtWidgets import QPushButton + + calls = [] + + class _Obj: + def handler(self): # 1-arg method, like _toggle_mute + calls.append(1) + + # Force a real wrapper regardless of the env var. + orig = perftrace.PERFTRACE_ENABLED + perftrace.PERFTRACE_ENABLED = True + try: + _Obj.handler = perftrace.traced("test.handler")(_Obj.handler) + finally: + perftrace.PERFTRACE_ENABLED = orig + + obj = _Obj() + btn = QPushButton() + btn.clicked.connect(obj.handler) + btn.click() # emits clicked(False) — must not raise, must run + assert calls == [1], "traced slot swallowed or crashed on the payload arg" + + def test_soak_function_exerciser_drives_real_cell(): """Every soak action runs against a real VideoCell through the real handlers, and the state invariants hold after each one."""