Skip to content

Commit dfbe965

Browse files
lukischclaude
andcommitted
fix: Bugsweep providers.py + watcher.py (3 Bugs, 4 Regressionstests)
Bug #BW-1 (providers.py): GoogleDriveProvider.resume() nutzte lexikografische Sortierung fuer versionierte Installationsordner; '9.0.0' rangierte vor '62.0.1'. Neue Hilfsfunktion _gdrive_version_key() parst Versionskomponenten als Integer-Tupel; _RESUME_BASE als Klassenattribut ermoeglicht sauberes Monkeypatching in Tests. Bug #BW-2 (watcher.py): PreventiveWatcher.tick() ignorierte den Rueckgabewert von resume(). Bei Fehlschlag blieb _paused_by_us=False + _last_activity=None; der Provider pausierte dauerhaft bis zum App-Neustart. Fix: bei resume()==False werden beide Zustandsfelder wiederhergestellt (Retry nach naechstem Cooldown). Symmetrisch zum in v0.2.2 behobenen pause()-Bug. Robustheit #BW-R (providers.py): _check_process() verglich exe_name case-sensitiv gegen tasklist-Output; Windows gibt Prozessnamen in Original-Schreibweise zurueck (z.B. 'Nextcloud.exe', nicht 'nextcloud.exe'). Fix: .lower() beidseitig. Tests: 4 neue Regressionstests, 115/115 gruen (Baseline war 111). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 97b4c54 commit dfbe965

5 files changed

Lines changed: 173 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,37 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
1313
`0x8007016A`, and cloud-sync locked-folder retry workflows.
1414

1515
### Behoben / Fixed
16+
- **Tray task dialog now supports files as well as folders:** the GUI no longer
17+
forces source selection through a folder-only picker, so delayed rename/move/delete
18+
actions cover the same file/folder scope that the product documentation promises.
1619
- **Autostart in packaged builds:** PyInstaller/Frozen builds now register the
1720
packaged executable instead of the source-tree `clf_launcher.pyw`.
1821
- `tests/source_platform_smoke.py`: headless Smoke-Tests für Linux und macOS — prüft Modul-Import, Version, `ops`-Operationen (rename/move/delete), `models.Queue`-Persistenz, `paths.data_dir()` und `worker.run_once()` ohne Cloud-Client oder GUI.
1922
- `.github/workflows/source-platform-smoke.yml`: CI-Matrix für `ubuntu-latest` und `macos-latest`, die die Smoke-Tests bei jedem Push/PR auf `main` ausführt.
2023

24+
### Behoben / Fixed
25+
- **Bug #BW-1 — GoogleDriveProvider.resume() semantische Versionssortierung:**
26+
`sorted(glob("*/GoogleDriveFS.exe"), reverse=True)` nutzte lexikografische Sortierung;
27+
Versionsordner wie `"9.0.0"` rangierten dabei über `"62.0.1"` (`'9' > '6'`
28+
zeichenweise). Fix: neue Hilfsfunktion `_gdrive_version_key(p)` parst den
29+
Verzeichnisnamen als Integer-Tupel; `_RESUME_BASE` als Klassenattribut ermöglicht
30+
zudem sauberes Monkeypatching in Tests. Betrifft nur Installationen mit mehreren
31+
parallelen Google-Drive-Versionen (z. B. nach unvollständigem Update). Test:
32+
`test_googledrive_version_sort_key_is_semantic`, `test_googledrive_resume_picks_highest_semantic_version`.
33+
- **Bug #BW-2 — PreventiveWatcher.tick() ignorierte resume()-Rückgabewert:**
34+
`self.provider.resume()` wurde ohne Prüfung des Rückgabewerts aufgerufen. Bei
35+
Fehlschlag (z. B. Prozess-Start verweigert) blieb `_paused_by_us=False` und
36+
`_last_activity=None` — der Watcher lief keinen neuen Cooldown, der Provider
37+
pausierte dauerhaft bis zum App-Neustart. Symmetrisch zum bereits in v0.2.2
38+
behobenen `pause()`-Bug. Fix: bei `resume() == False` werden `_paused_by_us=True`
39+
und `_last_activity=self._time()` wiederhergestellt, um nach dem nächsten
40+
Cooldown einen Retry zu ermöglichen. Test: `test_watcher_tick_retries_resume_after_failure`.
41+
- **Robustheit #BW-R — _check_process() Groß-/Kleinschreibungsvergleich:**
42+
`exe_name in out` war case-sensitiv; Windows' `tasklist` gibt Prozessnamen in der
43+
tatsächlichen Binär-Schreibweise zurück (z. B. `Nextcloud.exe`, nicht
44+
`nextcloud.exe`). Fix: `exe_name.lower() in out.lower()`. Test:
45+
`test_check_process_case_insensitive_exe_name`.
46+
2147
### Behoben / Fixed
2248
- **Bug 4 — falsches „completed" bei leerem Verzeichnis mit gesperrtem Eigen-Handle:**
2349
`_delete_dir_skip_locked()` meldete Erfolg anhand von `len(locked) == 0`. Ein

src/cloudlockfixer/providers.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ def _dedup_paths(paths: list[Path]) -> list[Path]:
6969
return out
7070

7171

72+
def _gdrive_version_key(p: Path) -> tuple[int, ...]:
73+
"""Semantischer Sortier-Schlüssel für versionierte GoogleDriveFS.exe-Pfade.
74+
75+
Parst den Verzeichnisnamen des exe-Pfads als Integer-Tupel, damit z. B.
76+
'62.0.1' numerisch über '9.0.0' sortiert ('9' > '6' lexikografisch wäre falsch).
77+
Ungültige Komponenten werden auf (0,) normiert.
78+
"""
79+
try:
80+
return tuple(int(x) for x in p.parent.name.split("."))
81+
except ValueError:
82+
return (0,)
83+
84+
7285
def _check_process(exe_name: str) -> bool:
7386
if sys.platform != "win32":
7487
return False
@@ -78,7 +91,7 @@ def _check_process(exe_name: str) -> bool:
7891
capture_output=True, text=True, timeout=10,
7992
encoding="utf-8", errors="ignore",
8093
).stdout or ""
81-
return exe_name in out
94+
return exe_name.lower() in out.lower()
8295
except (OSError, subprocess.SubprocessError):
8396
return False
8497

@@ -182,6 +195,7 @@ def resume(self) -> bool:
182195
class GoogleDriveProvider(SyncProvider):
183196
name = "Google Drive"
184197
mount_type = "virtual"
198+
_RESUME_BASE: Path = Path(r"C:\Program Files\Google\Drive File Stream")
185199

186200
def _detect_roots(self) -> list[Path]:
187201
if sys.platform != "win32":
@@ -204,10 +218,11 @@ def pause(self) -> bool:
204218
def resume(self) -> bool:
205219
if sys.platform != "win32":
206220
return False
207-
base = Path(r"C:\Program Files\Google\Drive File Stream")
221+
base = self._RESUME_BASE
208222
if not base.exists():
209223
return False
210-
versions = sorted(base.glob("*/GoogleDriveFS.exe"), reverse=True)
224+
versions = sorted(base.glob("*/GoogleDriveFS.exe"),
225+
key=_gdrive_version_key, reverse=True)
211226
for exe in versions:
212227
try:
213228
subprocess.Popen([str(exe)])

src/cloudlockfixer/watcher.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,16 @@ def tick(self) -> str:
9595
else:
9696
self._paused_by_us = False
9797
elif action == "resume":
98-
self.provider.resume()
99-
log.info("Preventive watcher: %s resumed (idle).",
100-
self.provider.name)
98+
if self.provider.resume():
99+
log.info("Preventive watcher: %s resumed (idle).",
100+
self.provider.name)
101+
else:
102+
# resume() fehlgeschlagen — Zustand wiederherstellen damit
103+
# nach dem nächsten Cooldown ein neuer Versuch gestartet wird.
104+
self._paused_by_us = True
105+
self._last_activity = self._time()
106+
log.warning("Preventive watcher: resume %s fehlgeschlagen, "
107+
"Retry nach Cooldown.", self.provider.name)
101108
return action
102109

103110

tests/test_p2_p3.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,54 @@ def tick(self):
262262
assert False, f"tick_all() darf keinen RuntimeError erzeugen: {e}"
263263

264264
assert ticked, "Watcher muss aufgerufen worden sein"
265+
266+
267+
# ── Bug #BW-2: tick() ignoriert resume()-Rückgabewert ─────────────────────────
268+
269+
def test_watcher_tick_retries_resume_after_failure(monkeypatch):
270+
"""Bug #BW-2: resume() Rückgabewert wurde in tick() ignoriert.
271+
Wenn resume() False zurückgibt, blieb _paused_by_us=False und der Provider
272+
pausierte dauerhaft bis zum App-Neustart — symmetrisch zum bereits behobenen
273+
pause()-Bug (v0.2.1 CHANGELOG).
274+
Fix: nach gescheitertem resume() _paused_by_us und _last_activity
275+
wiederherstellen, damit nach dem nächsten Cooldown ein Retry-Versuch erfolgt."""
276+
clock = [0.0]
277+
278+
class FailResumeProvider(FakeProvider):
279+
resume_calls = 0
280+
281+
def resume(self) -> bool:
282+
self.resume_calls += 1
283+
return False # simuliert Prozess-Start-Fehler
284+
285+
prov = FailResumeProvider()
286+
w = PreventiveWatcher(prov, threshold=3, cooldown_s=10,
287+
time_fn=lambda: clock[0])
288+
289+
# Phase 1: hohe Aktivität → pause() erfolgreich (FakeProvider.pause() gibt True)
290+
monkeypatch.setattr(w, "count_recent_changes", lambda: 10)
291+
assert w.tick() == "pause"
292+
assert w._paused_by_us
293+
294+
# Phase 2: Cooldown abgewartet → resume() schlägt fehl
295+
monkeypatch.setattr(w, "count_recent_changes", lambda: 0)
296+
clock[0] += 11
297+
assert w.tick() == "resume"
298+
assert prov.resume_calls == 1
299+
300+
# Kern-Assert: Zustand muss nach gescheitertem resume() wiederhergestellt sein
301+
assert w._paused_by_us, (
302+
"_paused_by_us muss nach gescheitertem resume() True sein — "
303+
"sonst bleibt der Provider dauerhaft pausiert"
304+
)
305+
assert w._last_activity is not None, (
306+
"_last_activity muss nach gescheitertem resume() gesetzt sein — "
307+
"sonst läuft kein neuer Cooldown und kein Retry erfolgt"
308+
)
309+
310+
# Phase 3: zweiter Cooldown → resume() wird erneut versucht (Retry)
311+
clock[0] += 11
312+
assert w.tick() == "resume", (
313+
"Nach erneutem Cooldown muss resume() erneut versucht werden"
314+
)
315+
assert prov.resume_calls == 2, "resume() muss ein zweites Mal aufgerufen worden sein"

tests/test_providers_multi.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,71 @@ def test_available_providers_returns_list():
249249
result = available_providers()
250250
assert isinstance(result, list)
251251
assert all(isinstance(p, SyncProvider) for p in result)
252+
253+
254+
# ── Bug #BW-1: GoogleDrive resume() — semantische Versionssortierung ──────────
255+
256+
def test_googledrive_version_sort_key_is_semantic():
257+
"""Bug #BW-1: sorted(glob, reverse=True) nutzt lexikografische Sortierung;
258+
'9.0.0' > '62.0.1' als String — '9' > '6' zeichenweise. Numerisch gilt
259+
62 > 9. Die extrahierte _gdrive_version_key-Funktion muss integer-basiert
260+
sortieren."""
261+
from cloudlockfixer.providers import _gdrive_version_key
262+
263+
paths = [
264+
Path("9.0.0") / "GoogleDriveFS.exe",
265+
Path("62.0.1") / "GoogleDriveFS.exe",
266+
Path("8.99.9") / "GoogleDriveFS.exe",
267+
]
268+
ordered = sorted(paths, key=_gdrive_version_key, reverse=True)
269+
names = [p.parent.name for p in ordered]
270+
assert names == ["62.0.1", "9.0.0", "8.99.9"], (
271+
f"Erwartet semantische Reihenfolge [62.0.1, 9.0.0, 8.99.9], war: {names}"
272+
)
273+
274+
275+
def test_googledrive_resume_picks_highest_semantic_version(tmp_path, monkeypatch):
276+
"""Integration: resume() muss die semantisch höchste Version zuerst versuchen.
277+
Bug #BW-1: ohne Fix startet lexikografisches '9.0.0' vor semantisch höherem '62.0.1'."""
278+
import subprocess as sp
279+
280+
base = tmp_path / "Drive File Stream"
281+
for ver in ("9.0.0", "62.0.1", "8.99.9"):
282+
(base / ver).mkdir(parents=True)
283+
(base / ver / "GoogleDriveFS.exe").write_text("x", encoding="utf-8")
284+
285+
started: list[str] = []
286+
287+
def fake_popen(cmd):
288+
started.append(Path(cmd[0]).parent.name)
289+
raise OSError("Testumgebung: kein echter Start")
290+
291+
monkeypatch.setattr(sp, "Popen", fake_popen)
292+
monkeypatch.setattr(GoogleDriveProvider, "_RESUME_BASE", base, raising=False)
293+
294+
GoogleDriveProvider().resume()
295+
296+
assert started, "Popen wurde nicht aufgerufen — _RESUME_BASE-Patch prüfen"
297+
assert started[0] == "62.0.1", (
298+
f"Höchste semantische Version '62.0.1' muss zuerst versucht werden, "
299+
f"war: {started[0]!r}"
300+
)
301+
302+
303+
# ── Robustheit: _check_process() — Groß-/Kleinschreibung ──────────────────────
304+
305+
def test_check_process_case_insensitive_exe_name(monkeypatch):
306+
"""Robustheit: tasklist gibt Prozessnamen in System-Schreibweise zurück
307+
(z.B. 'Nextcloud.exe'). Wenn wir mit 'nextcloud.exe' suchen, muss der
308+
Vergleich trotzdem True liefern (case-insensitive)."""
309+
from cloudlockfixer.providers import _check_process
310+
311+
class _Fake:
312+
stdout = "Nextcloud.exe 6789 Console 1 5.564 K\n"
313+
314+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _Fake())
315+
316+
# Suche mit Kleinbuchstaben — muss True ergeben trotz Groß-N in der Ausgabe
317+
assert _check_process("nextcloud.exe") is True, (
318+
"case-insensitive Suche muss Treffer finden wenn Ausgabe 'Nextcloud.exe' enthält"
319+
)

0 commit comments

Comments
 (0)