v0.2.2: device-sync guards — quit warning, sleep/shutdown inhibition

Quitting mid-transfer now asks (Keep Syncing / Quit Anyway) from
closeEvent, which every quit path hits — including the self-update
restart, which previously bypassed closeEvent via a bare quit() and now
routes through close(). A running sync holds its own GNOME inhibitor
(suspend + logout, "Syncing a playlist to a device") so the machine
won't sleep or shutdown/restart under a copy.

Along the way: the Inhibit D-Bus call marshaled Python ints as signed
against GNOME's (susu) signature, so every call was rejected and the
playback sleep inhibitor had silently never worked — _uint fixes both
holders (verified live: cookies taken, IsInhibited flips, releases
clean).

Also investigated trav's mid-copy Syncthing question: the transfer works
from a click-time snapshot and never reads live library state, so remote
changes can't corrupt it — no state freeze needed. Hardened the one real
gap: a source file relocated under the queue (remote metadata edit
moving files) is now skipped, dropped from the m3u, and reported,
instead of aborting the whole sync.
This commit is contained in:
2026-08-07 17:59:25 -04:00
parent dd3d86aa5c
commit a0a1aea57e
7 changed files with 251 additions and 24 deletions
+6 -5
View File
@@ -177,15 +177,16 @@ class TestWorker:
assert seen[-1][0] == seen[-1][1] == 2 * device_sync.CHUNK // 1024
assert "1/1" in seen[-1][2]
def test_source_vanishing_reports_failure(self, qapp, tmp_path):
def test_device_side_error_reports_failure(self, qapp, tmp_path):
# A file squatting on the dest-dir path stands in for a device-side
# write error (unplug mid-sync). Vanished *sources* are tolerated
# (round 27); device errors still abort.
a = _audio(tmp_path, "a.mp3")
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
a.unlink() # unplugged/deleted between plan and run
(tmp_path / "Music").mkdir()
(tmp_path / "Music" / "Mix").write_bytes(b"not a dir")
result = self._synced(qapp, plan)
assert "Sync failed" in result["error"]
# No half-copied file left behind at full-looking size.
leftover = plan.dest_dir / "X - A.mp3"
assert not leftover.exists() or leftover.stat().st_size != plan.copies[0].size
def test_empty_playlist_clears_folder(self, qapp, tmp_path):
dest = tmp_path / "Music" / "Mix"
+140
View File
@@ -0,0 +1,140 @@
"""Round 27: device-sync guards.
A running transfer now (a) holds a GNOME suspend+logout inhibitor so the
machine won't sleep or restart under it, (b) is protected by a quit-warning
in MainWindow.closeEvent (not testable headless — no MainWindow tests exist),
and (c) tolerates a source file vanishing mid-queue: Syncthing applying a
remote metadata edit can relocate a music file between planning and copying,
so the worker skips it, drops it from the m3u, and reports it instead of
aborting the whole transfer. Device-side errors still abort.
"""
from lintunes.device_sync import DeviceSyncWorker, plan_sync
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
from lintunes.models import Track
def _track(tid, name, artist, path):
return Track(track_id=tid, name=name, artist=artist,
location=str(path), total_time=180_000)
def _audio(tmp_path, filename, data=b"x" * 100):
path = tmp_path / "local" / filename
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
class _FakeIface:
"""Stands in for the QDBusInterface: records calls, hands out cookies."""
def __init__(self):
self.calls = []
self._next_cookie = 41
def isValid(self):
return True
def call(self, method, *args):
from PyQt6.QtDBus import QDBusMessage
self.calls.append((method, *args))
stub = QDBusMessage.createMethodCall("a.b", "/", "a.b", method)
if method == "Inhibit":
self._next_cookie += 1
return stub.createReply([self._next_cookie])
return stub.createReply([])
class TestSleepInhibitor:
def _patched(self, monkeypatch, **kwargs):
# _uint wraps ints in write-only QDBusArguments; identity here so the
# fake can record and assert on the raw values.
from lintunes import inhibit as inhibit_module
monkeypatch.setattr(inhibit_module, "_uint", lambda v: v)
inhibitor = SleepInhibitor(**kwargs)
inhibitor._iface = _FakeIface()
return inhibitor
def test_custom_reason_and_flags_reach_dbus(self, qapp, monkeypatch):
inhibitor = self._patched(
monkeypatch, reason="Syncing a playlist to a device",
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
inhibitor.inhibit()
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
assert method == "Inhibit"
assert reason == "Syncing a playlist to a device"
assert flags == 5 # logout (1) + suspend (4)
def test_defaults_unchanged_for_playback(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
method, app_id, xid, reason, flags = inhibitor._iface.calls[0]
assert reason == "Playing music"
assert flags == INHIBIT_SUSPEND == 4
def test_release_uninhibits_with_cookie(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
cookie = inhibitor._cookie
inhibitor.release()
assert ("Uninhibit", cookie) in inhibitor._iface.calls
assert inhibitor._cookie is None
def test_second_inhibit_while_held_is_noop(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.inhibit()
inhibitor.inhibit()
assert len([c for c in inhibitor._iface.calls
if c[0] == "Inhibit"]) == 1
def test_release_without_hold_is_noop(self, qapp, monkeypatch):
inhibitor = self._patched(monkeypatch)
inhibitor.release()
assert inhibitor._iface.calls == []
class TestVanishedSource:
def _run(self, plan):
worker = DeviceSyncWorker(plan)
results = {}
worker.finished.connect(lambda s: results.update(s))
worker.failed.connect(lambda m: results.update(error=m))
worker._run()
return results
def test_vanished_source_is_skipped_not_fatal(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3", b"a" * 50)
b = _audio(tmp_path, "b.mp3", b"b" * 70)
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
a.unlink() # Syncthing relocated it between plan and copy
summary = self._run(plan)
assert "error" not in summary
assert summary["vanished"] == 1
assert summary["copied"] == 1
assert not (plan.dest_dir / "X - A.mp3").exists()
assert (plan.dest_dir / "Y - B.mp3").read_bytes() == b"b" * 70
def test_vanished_track_left_out_of_m3u(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3")
b = _audio(tmp_path, "b.mp3")
tracks = [_track(1, "A", "X", a), _track(2, "B", "Y", b)]
plan = plan_sync("Mix", tracks, tmp_path / "Music")
a.unlink()
self._run(plan)
m3u = (plan.dest_dir / "Mix.m3u").read_text(encoding="utf-8")
assert "X - A.mp3" not in m3u
assert "Y - B.mp3" in m3u
def test_all_sources_vanished_still_finishes(self, qapp, tmp_path):
a = _audio(tmp_path, "a.mp3")
plan = plan_sync("Mix", [_track(1, "A", "X", a)], tmp_path / "Music")
a.unlink()
summary = self._run(plan)
assert summary["vanished"] == 1 and summary["copied"] == 0
assert (plan.dest_dir / "Mix.m3u").read_text() == "#EXTM3U\n"