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:
@@ -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()
|
||||
Reference in New Issue
Block a user