From 690721bd3a095841e17141da107d243750c425c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:09:04 -0400 Subject: [PATCH] fix(ledmatrix-music): skip websocket UTF-8 validation on the YTM socket (1.5.5) websocket-client validates every text frame's UTF-8 in pure Python unless wsaccel is installed, and holds the GIL while it does. The YTM Companion pushes its whole player state (~59KB, mostly the queue) every few seconds while music plays. On hdpi (512x64, Pi 4) a py-spy --gil profile put ~16% of the display process's GIL time in websocket/_utils.py _validate_utf8/_decode, and scroll stalls rose from ~0.6% of frames to ~8.5% from the moment the socket connected; with this change they were 0.89% with music playing. Pass skip_utf8_validation through socketio.Client's websocket_extra_options (engineio >= 4.8, already implied by the python-socketio>=5.11 floor). The payload is still decoded and parsed as JSON, which rejects anything malformed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BkfgXMqqwn2w4NN7LRzhxy --- plugins.json | 7 ++-- plugins/ledmatrix-music/CHANGELOG.md | 11 +++++ plugins/ledmatrix-music/manifest.json | 8 +++- .../test_ytm_skip_utf8_validation.py | 42 +++++++++++++++++++ plugins/ledmatrix-music/ytm_client.py | 9 ++++ 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 plugins/ledmatrix-music/test_ytm_skip_utf8_validation.py diff --git a/plugins.json b/plugins.json index 2c9e4c70..5ff2cdc8 100644 --- a/plugins.json +++ b/plugins.json @@ -599,15 +599,14 @@ "plugin_path": "plugins/ledmatrix-music", "stars": 0, "downloads": 0, - "last_updated": "2026-10-02", + "last_updated": "2026-10-03", "verified": true, "screenshot": "", - "latest_version": "1.5.4", + "latest_version": "1.5.5", "ledmatrix_min_version": "2.0.0", "aliases": [ "ledmatrix-music" - ], - "commit": "886d2038d22a35c962beac0ea261543339cffec9" + ] }, { "id": "news", diff --git a/plugins/ledmatrix-music/CHANGELOG.md b/plugins/ledmatrix-music/CHANGELOG.md index 8a3344b6..9d320672 100644 --- a/plugins/ledmatrix-music/CHANGELOG.md +++ b/plugins/ledmatrix-music/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.5.5] - 2026-10-03 + +### Fixed +- Scrolling no longer stutters while YouTube Music is playing. websocket-client + validates every text frame's UTF-8 in pure Python (unless wsaccel is + installed), holding the GIL; the companion pushes its whole player state + every few seconds, and on a 512x64 Pi 4 that check was ~16% of the display + process's GIL time and stalled ~8% of scroll frames (0.6% without it). The + socket now skips it via `websocket_extra_options`; the payload is still + decoded and parsed as JSON, which rejects anything malformed. + ## [1.5.4] - 2026-10-02 ### Fixed diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index e838c0ad..c2bc4a1b 100644 --- a/plugins/ledmatrix-music/manifest.json +++ b/plugins/ledmatrix-music/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-music", "name": "Music Player - Now Playing", - "version": "1.5.4", + "version": "1.5.5", "description": "Real-time now playing display for Spotify and YouTube Music with album art, scrolling text, and progress bars", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -65,6 +65,12 @@ } ], "versions": [ + { + "version": "1.5.5", + "released": "2026-10-03", + "ledmatrix_min_version": "2.0.0", + "notes": "Scrolling no longer stutters while YouTube Music is playing. The YTM connection checked every incoming message's text in slow pure-Python code, blocking the display for tens of milliseconds every few seconds; it now skips that check (the message is still parsed as JSON, which rejects anything malformed)." + }, { "version": "1.5.4", "released": "2026-10-02", diff --git a/plugins/ledmatrix-music/test_ytm_skip_utf8_validation.py b/plugins/ledmatrix-music/test_ytm_skip_utf8_validation.py new file mode 100644 index 00000000..313b1118 --- /dev/null +++ b/plugins/ledmatrix-music/test_ytm_skip_utf8_validation.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Tests that the YTM websocket skips websocket-client's UTF-8 validation. + +Regression under test: without wsaccel, websocket-client validates every text +frame's UTF-8 byte by byte in pure Python while holding the GIL. The YTM +Companion pushes its full player state every few seconds while music plays; +on a 512x64 Pi 4 rig that validation took ~16% of the display process's GIL +time and stalled ~8% of scroll frames (0.6% without it), visible as the +scroll pausing and jumping. + +Run: /bin/python plugins/ledmatrix-music/test_ytm_skip_utf8_validation.py +""" + +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + import socketio # noqa: F401 +except ImportError: + print("SKIP: python-socketio not installed (see requirements.txt)") + sys.exit(2) + +import ytm_client # noqa: E402 + + +def test_websocket_is_opened_without_utf8_validation(): + client = ytm_client.YTMClient() + options = client.sio.eio.websocket_extra_options + assert options.get('skip_utf8_validation') is True, options + + +def main(): + test_websocket_is_opened_without_utf8_validation() + print("OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/ledmatrix-music/ytm_client.py b/plugins/ledmatrix-music/ytm_client.py index 28382ec6..b5e90fe6 100644 --- a/plugins/ledmatrix-music/ytm_client.py +++ b/plugins/ledmatrix-music/ytm_client.py @@ -59,10 +59,19 @@ def __init__(self, update_callback=None, logger=None): # class had no say in. connect_client() owns retry timing; the poll # loop calls it, and the backoff applies uniformly whether the # connection never came up or came up and went away. + # + # skip_utf8_validation: websocket-client checks every text frame's + # UTF-8 in pure Python unless wsaccel is installed, holding the GIL + # throughout. The companion pushes its whole player state every few + # seconds while music plays, and on a Pi 4 that check was ~16% of the + # display process's GIL time and stalled ~8% of scroll frames. The + # payload is decoded and parsed as JSON right after, which rejects + # anything malformed anyway. self.sio = socketio.Client( logger=False, engineio_logger=False, reconnection=False, + websocket_extra_options={'skip_utf8_validation': True}, ) self.last_known_track_data = None self.is_connected = False