A single Right-arrow press skipped 1,676 tracks in 81 s and hard-froze the desktop. On Wayland key repeat is generated by the client until the compositor delivers the release, and the key filter treated every repeat as a new press. Each skip sent gnome-shell an MPRIS Metadata whose xesam:artist was a plain Python list, which marshals as "av" instead of "as". gnome-shell logged an error for every one (50k/min) and reloaded the cover, and that load is what kept it from ever delivering the release. Space/Left/Right now act once per press and swallow the repeats. xesam:artist goes out through mpris.string_array, which build_properties_changed now shares for invalidated_properties. The new test round-trips the signal into GDBus, which is what gnome-shell validates with, and fails 'av' == 'as' against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
"""Round 56: one held arrow key hard-froze the desktop.
|
|
|
|
Incident 2026-09-17: a single Right press became 1,676 track skips in 81 s.
|
|
On Wayland key repeat is generated by the *client* until the compositor
|
|
delivers the release, and every skip sent gnome-shell an MPRIS Metadata whose
|
|
xesam:artist was typed "av" rather than "as" — an error plus a cover reload
|
|
per skip, which kept gnome-shell too busy to deliver that release. Both halves
|
|
are pinned here: transport keys act once per press, and xesam:artist goes out
|
|
as a real string array.
|
|
"""
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from PyQt6.QtCore import QCoreApplication, QEvent, Qt
|
|
from PyQt6.QtGui import QKeyEvent
|
|
|
|
from lintunes.gui import main_window
|
|
from lintunes.gui.main_window import MainWindow
|
|
|
|
|
|
# ---- transport keys ignore auto-repeat ----
|
|
|
|
def _window_stub():
|
|
return SimpleNamespace(
|
|
isActiveWindow=lambda: True,
|
|
play_pause=MagicMock(),
|
|
player=MagicMock(),
|
|
_log_key=MagicMock(),
|
|
)
|
|
|
|
|
|
def _press(key, repeat):
|
|
return QKeyEvent(QEvent.Type.KeyPress, key,
|
|
Qt.KeyboardModifier.NoModifier, "", repeat)
|
|
|
|
|
|
@pytest.fixture
|
|
def no_focus_widget(monkeypatch):
|
|
monkeypatch.setattr(main_window.QApplication, "focusWidget",
|
|
staticmethod(lambda: None))
|
|
|
|
|
|
@pytest.mark.parametrize("key, action", [
|
|
(Qt.Key.Key_Right, lambda w: w.player.next),
|
|
(Qt.Key.Key_Left, lambda w: w.player.previous),
|
|
(Qt.Key.Key_Space, lambda w: w.play_pause),
|
|
])
|
|
def test_held_key_acts_once(qapp, no_focus_widget, key, action):
|
|
win = _window_stub()
|
|
assert MainWindow.eventFilter(win, None, _press(key, repeat=False))
|
|
for _ in range(50):
|
|
# Swallowed, not passed through: a held Right mustn't walk the
|
|
# table's cursor instead.
|
|
assert MainWindow.eventFilter(win, None, _press(key, repeat=True))
|
|
assert action(win).call_count == 1
|
|
assert win._log_key.call_count == 1
|
|
|
|
|
|
def test_separate_presses_each_act(qapp, no_focus_widget):
|
|
win = _window_stub()
|
|
for _ in range(3):
|
|
MainWindow.eventFilter(win, None, _press(Qt.Key.Key_Right, False))
|
|
assert win.player.next.call_count == 3
|
|
|
|
|
|
# ---- xesam:artist is "as" ----
|
|
|
|
def _adaptor_metadata(monkeypatch):
|
|
from lintunes import mpris
|
|
monkeypatch.setattr(mpris, "export_artwork", lambda track: None)
|
|
track = SimpleNamespace(track_id=7, name="I Know", artist="Dionne Farris",
|
|
album="Wild Seed — Wild Flower", total_time=229000,
|
|
location="/music/i-know.mp3", persistent_id="AB12")
|
|
adaptor = SimpleNamespace(_player=SimpleNamespace(current_track=track))
|
|
return mpris.MprisPlayerAdaptor._metadata(adaptor)
|
|
|
|
|
|
def test_artist_is_typed_string_array(qapp, monkeypatch):
|
|
# A plain [str] is what went out as "av"; only a typed QDBusArgument
|
|
# carries "as". (The wire type itself is checked over the bus below.)
|
|
from PyQt6.QtDBus import QDBusArgument
|
|
meta = _adaptor_metadata(monkeypatch)
|
|
assert isinstance(meta["xesam:artist"], QDBusArgument)
|
|
|
|
|
|
def test_gdbus_receives_artist_as_string_array(qapp, monkeypatch):
|
|
"""Round-trip over the real session bus into GDBus — the same library
|
|
gnome-shell validates with — and check the type it actually sees."""
|
|
Gio = pytest.importorskip("gi.repository.Gio")
|
|
GLib = pytest.importorskip("gi.repository.GLib")
|
|
from PyQt6.QtDBus import QDBusConnection
|
|
from lintunes.mpris import OBJECT_PATH, build_properties_changed
|
|
|
|
bus = QDBusConnection.sessionBus()
|
|
if not bus.isConnected():
|
|
pytest.skip("no D-Bus session bus in this environment")
|
|
try:
|
|
gbus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
except GLib.Error:
|
|
pytest.skip("GDBus can't reach the session bus")
|
|
|
|
received = []
|
|
sub = gbus.signal_subscribe(
|
|
bus.baseService(), "org.freedesktop.DBus.Properties",
|
|
"PropertiesChanged", OBJECT_PATH, None, Gio.DBusSignalFlags.NONE,
|
|
lambda *args: received.append(args[5]))
|
|
try:
|
|
meta = _adaptor_metadata(monkeypatch)
|
|
assert bus.send(build_properties_changed({"Metadata": meta}))
|
|
ctx = GLib.MainContext.default()
|
|
for _ in range(200):
|
|
QCoreApplication.processEvents()
|
|
while ctx.iteration(False):
|
|
pass
|
|
if received:
|
|
break
|
|
threading.Event().wait(0.01)
|
|
finally:
|
|
gbus.signal_unsubscribe(sub)
|
|
|
|
assert received, "signal was not delivered over the bus"
|
|
params = received[0]
|
|
assert params.get_type_string() == "(sa{sv}as)"
|
|
metadata = params.get_child_value(1).lookup_value("Metadata", None)
|
|
artist = metadata.lookup_value("xesam:artist", None)
|
|
assert artist.get_type_string() == "as"
|
|
assert artist.unpack() == ["Dionne Farris"]
|