From c8ea2f6765f0fe92f582b2ae8de6667dc03a3aa9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 17 Jun 2026 05:13:40 +0200 Subject: [PATCH] Fix: Startup-Dedup ersetzt keine gute kanonische Datei mehr durch ein kleineres Duplikat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim Start gleicht das Tool Duplikat-benannte Dateien ("Name (1).ext") gegen die kanonische Datei ("Name.ext") ab. Im Zweig "Duplikat rangiert hoeher und das kanonische Item ist nicht 'completed'" wurde bisher bedingungslos fs.rmSync(kanonisch) + fs.renameSync(Duplikat → kanonisch) ausgefuehrt — ohne die echten Dateigroessen zu vergleichen. Der Rang mischt den persistierten STATUS mit der Disk-Existenz; nach einem Absturz kann der Status von der Realitaet abweichen. War die kanonische Datei auf der Platte tatsaechlich vollstaendig/gut, ihr Item aber z.B. "failed"/"queued", waehrend das Duplikat-Item ein veraltetes "completed" ueber einer kleineren/partiellen Datei trug, wurde die gute kanonische Datei geloescht und durch das schlechtere Duplikat ersetzt — unwiederbringlicher Verlust der besseren Datei. Zusatzrisiko: rmSync erfolgreich, renameSync wirft EXDEV → kanonisch weg, nichts installiert. Fix: - Size-Guard: Ist die kanonische Datei >= dem Duplikat, wird sie behalten und das (kleinere/gleich grosse) Duplikat verworfen. Eine kleinere Datei kann eine groessere nie ueberschreiben. - Sichere Reihenfolge fuer den echten Austausch (kanonisch kleiner): kanonisch → ".dedupbak" umbenennen, dann Duplikat → kanonisch; bei Fehler (EXDEV o.ae.) wird die kanonische Datei aus dem Backup wiederhergestellt. Kein Loss-Fenster mehr. Erst nach Erfolg wird ".dedupbak" entfernt. Test: kanonisch 1024 B (gut) + Duplikat 256 B (partiell), primaeres Item "failed" (Duplikat rangiert hoeher) → die kanonische Datei bleibt mit 1024 B erhalten. Ohne den Size-Guard wird sie durch die 256-B-Datei ersetzt (rot bewiesen). Volle download-manager-Suite (168) gruen, tsc unveraendert 6. --- src/main/download-manager.ts | 45 ++++++++++++----- tasks/audit-loop.md | 51 +++++++++++++++++-- tests/download-manager.test.ts | 90 ++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 18 deletions(-) diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 9ef5dda..01a8b86 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6392,19 +6392,38 @@ export class DownloadManager extends EventEmitter { } catch { } } else if (duplicateExists && canonicalExists && !primaryWins && primaryItem.status !== "completed") { - try { - fs.rmSync(canonicalPath, { force: true }); - fs.renameSync(duplicateTargetPath, canonicalPath); - canonicalExists = true; - this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath); - logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`); - } catch (err) { - logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", { - source: path.basename(duplicateTargetPath), - target: canonicalBaseName, - error: compactErrorText(err) - }); - logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`); + let canonicalSize = -1; + let duplicateSize = -1; + try { canonicalSize = fs.statSync(canonicalPath).size; } catch { } + try { duplicateSize = fs.statSync(duplicateTargetPath).size; } catch { } + if (canonicalSize >= 0 && duplicateSize >= 0 && canonicalSize >= duplicateSize) { + try { + fs.rmSync(duplicateTargetPath, { force: true }); + } catch { + } + logger.info(`startupDuplicateMerge: kanonische Datei behalten (${canonicalSize}B >= Duplikat ${duplicateSize}B), Duplikat verworfen: ${canonicalBaseName}`); + } else { + const dedupBackupPath = `${canonicalPath}.dedupbak`; + try { + fs.renameSync(canonicalPath, dedupBackupPath); + try { + fs.renameSync(duplicateTargetPath, canonicalPath); + try { fs.rmSync(dedupBackupPath, { force: true }); } catch { } + canonicalExists = true; + this.logVerifiedRenameSync("startup-dedup (Austausch)", duplicateTargetPath, canonicalPath); + logger.info(`startupDuplicateMerge: ersetze verwaisten Originalpfad ${canonicalBaseName} durch ${path.basename(duplicateTargetPath)}`); + } catch (swapErr) { + try { fs.renameSync(dedupBackupPath, canonicalPath); } catch { } + throw swapErr; + } + } catch (err) { + logDesktopRename("ERROR", "startup-dedup (Austausch): Rename fehlgeschlagen", { + source: path.basename(duplicateTargetPath), + target: canonicalBaseName, + error: compactErrorText(err) + }); + logger.warn(`startupDuplicateMerge: Austausch fehlgeschlagen ${canonicalPath}: ${compactErrorText(err)}`); + } } } diff --git a/tasks/audit-loop.md b/tasks/audit-loop.md index 4be9259..f91c139 100644 --- a/tasks/audit-loop.md +++ b/tasks/audit-loop.md @@ -65,8 +65,49 @@ daily-limit aggregate early-exit. - [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change. -### Batch 2 (geplant, naechste Runden) -- Web-Timeout-Selbstcooldown-Familie: #13 elapsedMs inkl. queue-wait → ranLongEnough-Gate auf WORK-Zeit - (mega-web traced workMs schon); + 60s-Caller-Timeout vs Web-Queue(90s) Mismatch. Braucht workMs-Threading. -- #5 Web echte Bad-Creds erreichen invalid nie. #6 onefichier/ddownload routing ignoriert fallback=off. -- #9 overwrite targetPath-wipe. #10 HTTP416 shared counter. #11 fresh-retry preempt. #12 shelve+shared counter. +### Strategie-Update (LIVE-Server, Advisor-bestaetigt) +- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist. + Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live, + auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko. +- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server. +- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail). + Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren. +- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr; + braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig. +- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend? + +## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv +14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code +verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6. + +### Batch 2b → noch nicht released (buendeln, dann v1.7.216) +- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille + Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet), + nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba. +- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie + beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert, + frisch + resume. End-to-end-Test. Commit 56bae4a. +- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief + cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) + + cancel in shutdown. Commit 1a33fc2. +- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor + Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7. +- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei + geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991. +- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss- + Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit + Restore. Commit folgt nach voller DM-Suite. +- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht + entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix. +- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht; + #R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo- + Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle). + +### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings) +- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow). + Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar). + ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet + (Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b. +- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter, + #11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds. + → je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert. diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 1f408f3..4caa7fb 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -536,6 +536,96 @@ describe("download manager", () => { expect(fs.existsSync(duplicatePath)).toBe(false); }); + it("does not replace a larger good canonical file with a smaller duplicate on startup dedup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-size-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "dup-size-pkg"; + const originalItemId = "dup-size-original"; + const duplicateItemId = "dup-size-copy"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "Dup Size"); + const extractDir = path.join(root, "extract", "Dup Size"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const canonicalPath = path.join(outputDir, "movie.mkv"); + const duplicatePath = path.join(outputDir, "movie (1).mkv"); + fs.writeFileSync(canonicalPath, Buffer.alloc(1024, 5)); + fs.writeFileSync(duplicatePath, Buffer.alloc(256, 9)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "Dup Size", + outputDir, + extractDir, + status: "completed", + itemIds: [originalItemId, duplicateItemId], + cancelled: false, + enabled: true, + priority: "normal", + createdAt, + updatedAt: createdAt + }; + session.items[originalItemId] = { + id: originalItemId, + packageId, + url: "https://example.com/movie.mkv", + provider: "realdebrid", + status: "failed", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: 1024, + progressPercent: 0, + fileName: "movie.mkv", + targetPath: canonicalPath, + resumable: true, + attempts: 1, + lastError: "Fehlgeschlagen", + fullStatus: "", + createdAt, + updatedAt: createdAt + 10_000 + }; + session.items[duplicateItemId] = { + id: duplicateItemId, + packageId, + url: "https://example.com/movie.mkv", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 256, + totalBytes: 256, + progressPercent: 100, + fileName: "movie.mkv", + targetPath: duplicatePath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig", + createdAt, + updatedAt: createdAt + 5_000 + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.statSync(canonicalPath).size).toBe(1024); + }); + it("keeps a stronger extracted canonical startup state when removing stale duplicate copies", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-keep-")); tempDirs.push(root);