v0.32.1: a stuck Rabbit connection can no longer freeze the window

Opening Connections called find_device() on the GUI thread, which lists the
gvfs-MTP mount; on a wedged mount that blocks forever in an uninterruptible
FUSE wait and the app had to be force-quit. DeviceWatcher now probes on a
daemon thread (one at a time, STUCK after 3 s, late answers still land), the
menu reads its cached answer, and a stuck mount shows "Rabbit isn't
responding…" with a Reset Connection button (kill gvfsd-mtp, remount via
gio). Tests can no longer reach the real mount (conftest patches
find_device), which also takes the suite from ~200 s to ~22 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:58:26 -07:00
co-authored by Claude Opus 5.5
parent ba795e3c69
commit 27abdbdb63
6 changed files with 382 additions and 18 deletions
+14 -5
View File
@@ -440,11 +440,20 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
is copied, writing nothing when nothing moved. `PlayJournal.load` then is copied, writing nothing when nothing moved. `PlayJournal.load` then
treats the R1 as one more machine. treats the R1 as one more machine.
**A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits**: `timeout` **A wedged gvfs-MTP mount blocks in uninterruptible FUSE waits**: `timeout`
can't kill a process stuck on it, and `find_device()` — called on the GUI can't kill a process stuck on it. USB re-enumeration (lock/unlock with
thread every time the Connections menu opens, and by the GUI tests — `mtp,adb`, an `adb install`) is what wedged it in Round 51. Recovery:
freezes with it. USB re-enumeration (lock/unlock with `mtp,adb`, an `kill` the `gvfsd-mtp` process, then `gio mount mtp://<device>/` —
`adb install`) is what wedged it in Round 51. Recovery: `kill` the `device_sync.reset_mtp_connection`, offered as **Reset Connection** when
`gvfsd-mtp` process, then `gio mount mtp://<device>/`. the menu item reads "Rabbit isn't responding…". Since Round 71 **the GUI
thread never calls `find_device()`**: it froze the window every time the
Connections menu opened on a wedged mount. `MainWindow._devices`
(`device_sync.DeviceWatcher`) probes on a daemon thread, one at a time,
and declares `STUCK` after 3 s; click handlers go through
`when_ready(callback)`. A stuck probe thread can't be killed, so quitting
then may still hang until the reset. `tests/conftest.py` patches
`find_device` to `None` for every test, and nothing — test or tool call —
may list `/run/user/*/gvfs`; ask `gio mount -l` instead, which goes over
D-Bus.
- **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's - **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's
right-click menu). A sibling of `device_sync`, reusing its filename helpers right-click menu). A sibling of `device_sync`, reusing its filename helpers
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.32.0" __version__ = "0.32.1"
+128
View File
@@ -63,6 +63,134 @@ def find_device() -> Device | None:
return find_rabbit() return find_rabbit()
UNKNOWN, PRESENT, ABSENT, STUCK = "unknown", "present", "absent", "stuck"
PROBE_TIMEOUT_MS = 3000
class DeviceWatcher(QObject):
"""``find_device()`` off the GUI thread, because a wedged gvfs-MTP mount
blocks it in an uninterruptible FUSE wait and nothing can kill it.
The Connections menu used to call ``find_device()`` on every open, which
froze the whole window the moment the mount wedged (Round 71). Now the
GUI only ever reads ``device``/``state``; ``probe()`` refreshes them on a
daemon thread. One probe at a time — a stuck one is never joined and
never stacked on — and one that hasn't answered within PROBE_TIMEOUT_MS
reports ``STUCK``. A late answer still lands, so a mount that was only
slow recovers by itself.
"""
changed = pyqtSignal()
_answered = pyqtSignal(object) # thread -> GUI thread (queued)
def __init__(self, finder=None, timeout_ms: int = PROBE_TIMEOUT_MS,
parent=None):
super().__init__(parent)
from PyQt6.QtCore import QTimer
self._finder = finder or find_device
self.device: Device | None = None
self.state = UNKNOWN
self._in_flight = False
self._waiters: list = []
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(timeout_ms)
self._timer.timeout.connect(self._on_timeout)
self._answered.connect(self._on_answer)
def probe(self):
"""Refresh in the background; a probe already running is enough."""
if self._in_flight:
return
self._in_flight = True
self._timer.start()
threading.Thread(target=self._run, daemon=True,
name="device-probe").start()
def when_ready(self, callback):
"""``callback(device, state)`` once a fresh probe answers, or as soon
as it is declared stuck — whichever comes first, exactly once."""
self._waiters.append(callback)
if self.state == STUCK and self._in_flight:
# Still the same stuck probe: no point waiting another timeout.
self._flush()
return
self.probe()
def _run(self):
try:
device = self._finder()
except Exception:
device = None
self._answered.emit(device)
def _on_answer(self, device):
self._in_flight = False
self._timer.stop()
self.device = device
self.state = PRESENT if device is not None else ABSENT
self.changed.emit()
self._flush()
def _on_timeout(self):
if not self._in_flight:
return
self.device = None
self.state = STUCK
self.changed.emit()
self._flush()
def _flush(self):
waiters, self._waiters = self._waiters, []
for callback in waiters:
callback(self.device, self.state)
def mtp_uris(gio_output: str) -> list[str]:
"""The Rabbit's ``mtp://…/`` URIs from ``gio mount -l`` output.
gio asks the gvfs daemon over D-Bus, so unlike listing the FUSE path it
answers even while the mount is wedged."""
uris = []
for match in re.finditer(r"(mtp://\S+)", gio_output):
uri = match.group(1)
if "rabbit" in uri.lower() and uri not in uris:
uris.append(uri)
return uris
def reset_mtp_connection(run=None) -> str | None:
"""Kill gvfsd-mtp and remount the Rabbit — the recovery for a wedged
mount. Blocking (subprocesses with timeouts); call it from a thread.
Returns None on success, else what went wrong."""
import subprocess
run = run or subprocess.run
try:
listing = run(["gio", "mount", "-l"], capture_output=True,
text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError) as e:
return f"gio isn't available: {e}"
uris = mtp_uris(listing)
try:
run(["pkill", "-x", "gvfsd-mtp"], timeout=10)
except (OSError, subprocess.SubprocessError) as e:
return f"couldn't stop gvfsd-mtp: {e}"
if not uris:
return None # nothing to remount; replugging will mount it
import time
time.sleep(1) # let gvfs notice the daemon went away
for uri in uris:
try:
result = run(["gio", "mount", uri], capture_output=True,
text=True, timeout=30)
except (OSError, subprocess.SubprocessError) as e:
return f"couldn't remount {uri}: {e}"
if result.returncode != 0 and "already mounted" not in (
result.stderr or "").lower():
return (result.stderr or "").strip() or f"gio mount {uri} failed"
return None
def sanitize_name(name: str) -> str: def sanitize_name(name: str) -> str:
"""A playlist/track name reduced to a safe cross-filesystem filename.""" """A playlist/track name reduced to a safe cross-filesystem filename."""
cleaned = " ".join(_FORBIDDEN.sub(" ", name).split()) cleaned = " ".join(_FORBIDDEN.sub(" ", name).split())
+82 -12
View File
@@ -1,4 +1,5 @@
import shutil import shutil
import threading
from pathlib import Path from pathlib import Path
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -6,7 +7,7 @@ from PyQt6.QtWidgets import (
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication, QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
QLabel, QMessageBox, QFileDialog, QProgressBar, QPushButton, QLabel, QMessageBox, QFileDialog, QProgressBar, QPushButton,
) )
from PyQt6.QtCore import Qt, QEvent, QTimer from PyQt6.QtCore import Qt, QEvent, QObject, QTimer, pyqtSignal
from PyQt6.QtGui import QAction, QKeySequence from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, music_folder, theme, url_import from lintunes import device_sync, music_folder, theme, url_import
@@ -82,6 +83,11 @@ class _CancelSyncButton(QPushButton):
super().leaveEvent(event) super().leaveEvent(event)
class _ResetRelay(QObject):
"""Carries reset_mtp_connection's answer back to the GUI thread."""
done = pyqtSignal(object)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self, manager, prefs, lastfm=None, parent=None): def __init__(self, manager, prefs, lastfm=None, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -93,6 +99,11 @@ class MainWindow(QMainWindow):
# Owns the cast session, if any. Built before the transport bar, which # Owns the cast session, if any. Built before the transport bar, which
# takes it to drive the little cast indicator under the volume slider. # takes it to drive the little cast indicator under the volume slider.
self._cast = CastController(self.player, self) self._cast = CastController(self.player, self)
# Where the Rabbit is, found off the GUI thread: a wedged gvfs-MTP
# mount blocks any listing of it forever (Round 71). Nothing probes
# until the Connections menu opens.
self._devices = device_sync.DeviceWatcher(parent=self)
self._devices.changed.connect(self._refresh_device_actions)
# Keep the machine awake while audio is actually playing. # Keep the machine awake while audio is actually playing.
self._inhibitor = SleepInhibitor() self._inhibitor = SleepInhibitor()
# Context playback started from: "library" or "playlist:<pid>". Scopes # Context playback started from: "library" or "playlist:<pid>". Scopes
@@ -413,8 +424,8 @@ class MainWindow(QMainWindow):
self._cassette.build_menu(connections_menu) self._cassette.build_menu(connections_menu)
self._cast_action = self._add_action( self._cast_action = self._add_action(
connections_menu, "Connect to Chromecast…", "", self._toggle_cast) connections_menu, "Connect to Chromecast…", "", self._toggle_cast)
# Re-checked every time the menu opens: cheap (one gvfs listdir), and # Re-checked every time the menu opens. The device part is a
# always reflects plug/unplug, the cast session and the current view. # background probe; its answer updates the open menu when it lands.
connections_menu.aboutToShow.connect(self._refresh_connection_actions) connections_menu.aboutToShow.connect(self._refresh_connection_actions)
@property @property
@@ -475,11 +486,8 @@ class MainWindow(QMainWindow):
def _refresh_connection_actions(self): def _refresh_connection_actions(self):
# An export in flight owns the same status-bar widgets, so it blocks # An export in flight owns the same status-bar widgets, so it blocks
# a sync just as another sync would. # a sync just as another sync would.
busy = self._busy_worker() is not None self._devices.probe()
device = device_sync.find_device() self._refresh_device_actions()
# Whole-library sync doesn't care which view is open — only that a
# device is plugged in and at least one playlist is ticked.
self._andtunes_action.setEnabled(not busy and device is not None)
self._cassette.refresh_menu() self._cassette.refresh_menu()
# Enabled whenever there's an APK to offer; the click finds the # Enabled whenever there's an APK to offer; the click finds the
# Rabbit (adb or MTP) and says so if it can't — cheaper than asking # Rabbit (adb or MTP) and says so if it can't — cheaper than asking
@@ -583,6 +591,57 @@ class MainWindow(QMainWindow):
# cancelled sync has news for the journals. # cancelled sync has news for the journals.
self._manager.reload_from_disk() self._manager.reload_from_disk()
def _refresh_device_actions(self):
"""Sync to Rabbit from the watcher's cached answer — never a fresh
look at the mount, which is what froze the menu."""
busy = self._busy_worker() is not None
if self._devices.state == device_sync.STUCK:
# Enabled: the click explains and offers the reset.
self._andtunes_action.setText("Rabbit isn't responding…")
self._andtunes_action.setEnabled(True)
return
self._andtunes_action.setText("Sync to Rabbit")
# Whole-library sync doesn't care which view is open — only that a
# device is plugged in and at least one playlist is ticked.
self._andtunes_action.setEnabled(
not busy and self._devices.device is not None)
def _rabbit_stuck(self):
"""The mount stopped answering: say so, and offer the CLAUDE.md
recovery (kill gvfsd-mtp, remount) as one button."""
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
box.setWindowTitle("Rabbit isn't responding")
box.setText("The Rabbit's USB connection has stopped answering, so "
"LinTunes can't reach it.")
box.setInformativeText(
"Reset Connection restarts the desktop's MTP service and "
"remounts the Rabbit. If that doesn't do it, unplug the Rabbit "
"and plug it back in.")
reset = box.addButton("Reset Connection",
QMessageBox.ButtonRole.AcceptRole)
box.addButton(QMessageBox.StandardButton.Cancel)
box.setDefaultButton(reset)
box.exec()
if box.clickedButton() is not reset:
return
self.statusBar().showMessage("Resetting the Rabbit connection…")
relay = self._reset_relay = _ResetRelay(self)
relay.done.connect(self._on_rabbit_reset)
threading.Thread(
target=lambda: relay.done.emit(device_sync.reset_mtp_connection()),
daemon=True).start()
def _on_rabbit_reset(self, error):
if error:
self.statusBar().clearMessage()
QMessageBox.warning(self, "Rabbit isn't responding",
f"The reset didn't work: {error}\n\n"
"Unplug the Rabbit and plug it back in.")
return
self.statusBar().showMessage("Rabbit connection reset", 6000)
self._devices.probe()
def _on_sync_failed(self, message): def _on_sync_failed(self, message):
self._sync_inhibitor.release() self._sync_inhibitor.release()
self._hide_sync_widgets() self._hide_sync_widgets()
@@ -591,7 +650,7 @@ class MainWindow(QMainWindow):
# ---- connections: andTunes (whole-library sync) ---- # ---- connections: andTunes (whole-library sync) ----
def _show_device_sync_settings(self): def _show_device_sync_settings(self):
device = device_sync.find_device() device = self._devices.device # only for its name
dialog = DeviceSyncSettingsDialog( dialog = DeviceSyncSettingsDialog(
self._prefs, self._manager.library, self, self._prefs, self._manager.library, self,
device_name=device.name if device else "Rabbit") device_name=device.name if device else "Rabbit")
@@ -606,8 +665,13 @@ class MainWindow(QMainWindow):
# Everything may have changed since the menu opened — re-verify. # Everything may have changed since the menu opened — re-verify.
if self._busy_worker() is not None: if self._busy_worker() is not None:
return return
device = device_sync.find_device() self._devices.when_ready(self._sync_to_device)
if device is None:
def _sync_to_device(self, device, state):
if state == device_sync.STUCK:
self._rabbit_stuck()
return
if device is None or self._busy_worker() is not None:
return return
playlists = self._selected_sync_playlists() playlists = self._selected_sync_playlists()
if not playlists: if not playlists:
@@ -731,9 +795,15 @@ class MainWindow(QMainWindow):
apk = andtunes_install.bundled_apk() apk = andtunes_install.bundled_apk()
if apk is None: if apk is None:
return return
self._devices.when_ready(
lambda device, state: self._install_andtunes_to(apk, device, state))
def _install_andtunes_to(self, apk, device, state):
adb = andtunes_install.adb_path() adb = andtunes_install.adb_path()
serial = andtunes_install.rabbit_serial(adb) if adb else None serial = andtunes_install.rabbit_serial(adb) if adb else None
device = device_sync.find_device() if serial is None and state == device_sync.STUCK:
self._rabbit_stuck()
return
if serial is None and device is None: if serial is None and device is None:
QMessageBox.information( QMessageBox.information(
self, "Rabbit not found", self, "Rabbit not found",
+9
View File
@@ -17,6 +17,15 @@ def _isolate_eventlog(tmp_path, monkeypatch):
lambda: tmp_path / "control-events.log") lambda: tmp_path / "control-events.log")
@pytest.fixture(autouse=True)
def _no_real_device(monkeypatch):
"""No test may look at the real gvfs mount: a wedged gvfs-MTP mount
blocks the listing uninterruptibly and hangs the suite (Round 71).
Tests that need a device patch their own finder or pass a fake root."""
from lintunes import device_sync
monkeypatch.setattr(device_sync, "find_device", lambda: None)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def qapp(): def qapp():
"""A Qt application so QObject/QTimer and real widgets work in tests. """A Qt application so QObject/QTimer and real widgets work in tests.
+148
View File
@@ -0,0 +1,148 @@
"""Round 71: a wedged Rabbit mount must never freeze the window.
The Connections menu used to call ``find_device()`` on the GUI thread, and on
a wedged gvfs-MTP mount that listing blocks forever in an uninterruptible FUSE
wait — trav had to force-quit. ``DeviceWatcher`` does the looking on a daemon
thread and gives up waiting (not the thread) after a timeout.
"""
import threading
import time
from PyQt6.QtCore import QCoreApplication
from lintunes import device_sync
from lintunes.device_sync import (
ABSENT, PRESENT, STUCK, Device, DeviceWatcher, mtp_uris,
reset_mtp_connection,
)
def _pump(until, timeout=3.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
QCoreApplication.processEvents()
if until():
return True
time.sleep(0.01)
return False
def _device(tmp_path):
return Device("Rabbit R1", tmp_path, tmp_path / "Music")
class TestWatcher:
def test_a_hung_finder_is_reported_stuck_without_blocking(self, qapp):
release = threading.Event()
watcher = DeviceWatcher(finder=lambda: release.wait() and None,
timeout_ms=50)
started = time.monotonic()
watcher.probe()
assert time.monotonic() - started < 0.5 # probe() returns at once
assert _pump(lambda: watcher.state == STUCK)
release.set()
def test_one_probe_at_a_time(self, qapp):
release = threading.Event()
calls = []
def finder():
calls.append(1)
release.wait()
watcher = DeviceWatcher(finder=finder, timeout_ms=50)
watcher.probe()
watcher.probe()
watcher.probe()
assert _pump(lambda: watcher.state == STUCK)
watcher.probe()
assert len(calls) == 1
release.set()
def test_a_late_answer_recovers(self, qapp, tmp_path):
release = threading.Event()
device = _device(tmp_path)
watcher = DeviceWatcher(finder=lambda: release.wait() and device,
timeout_ms=50)
watcher.probe()
assert _pump(lambda: watcher.state == STUCK)
release.set()
assert _pump(lambda: watcher.state == PRESENT)
assert watcher.device is device
def test_when_ready_fires_once_with_a_fresh_answer(self, qapp, tmp_path):
watcher = DeviceWatcher(finder=lambda: None)
seen = []
watcher.when_ready(lambda d, s: seen.append((d, s)))
assert _pump(lambda: seen)
assert seen == [(None, ABSENT)]
_pump(lambda: False, timeout=0.1)
assert len(seen) == 1
def test_when_ready_while_stuck_answers_immediately(self, qapp):
release = threading.Event()
watcher = DeviceWatcher(finder=lambda: release.wait(), timeout_ms=50)
watcher.probe()
assert _pump(lambda: watcher.state == STUCK)
seen = []
watcher.when_ready(lambda d, s: seen.append(s))
assert seen == [STUCK]
release.set()
class TestReset:
GIO = """Volume(0): Rabbit R1
Type: GProxyVolume (GProxyVolumeMonitorMTP)
Mount(0): Rabbit R1 -> mtp://unknown_Rabbit_R1_9191/
Mount(1): mtp -> mtp://unknown_Rabbit_R1_9191/
Mount(2): Phone -> mtp://Google_Pixel_1234/
"""
def test_uris_come_from_gio_not_the_mount(self):
assert mtp_uris(self.GIO) == ["mtp://unknown_Rabbit_R1_9191/"]
def test_reset_kills_then_remounts(self, monkeypatch):
monkeypatch.setattr(time, "sleep", lambda s: None)
commands = []
class R:
returncode, stderr = 0, ""
stdout = TestReset.GIO
def run(cmd, **kw):
commands.append(cmd)
return R()
assert reset_mtp_connection(run) is None
assert commands == [["gio", "mount", "-l"],
["pkill", "-x", "gvfsd-mtp"],
["gio", "mount", "mtp://unknown_Rabbit_R1_9191/"]]
class TestMenu:
def test_menu_open_never_waits_on_the_mount(self, qapp, monkeypatch,
tmp_path):
from lintunes.gui.main_window import MainWindow
from lintunes.library_manager import LibraryManager
from lintunes.models import Library
from lintunes.preferences import Preferences
release = threading.Event()
monkeypatch.setattr(device_sync, "find_device",
lambda: release.wait() and None)
manager = LibraryManager(Library(), tmp_path / "data")
window = MainWindow(manager, Preferences(tmp_path / "data"))
window._devices._timer.setInterval(50)
try:
started = time.monotonic()
window._refresh_connection_actions()
assert time.monotonic() - started < 0.5
assert not window._andtunes_action.isEnabled()
assert _pump(lambda: window._andtunes_action.text()
== "Rabbit isn't responding…")
assert window._andtunes_action.isEnabled()
finally:
release.set()
assert _pump(lambda: window._devices.state == ABSENT)
assert window._andtunes_action.text() == "Sync to Rabbit"
window.close()