Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hyperwall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
25 changes: 21 additions & 4 deletions hyperwall/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion hyperwall/perftrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions hyperwall/wall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"),
Expand Down
4 changes: 2 additions & 2 deletions tests/run_repo_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
30 changes: 30 additions & 0 deletions tests/test_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading