Fix: Startup-Dedup ersetzt keine gute kanonische Datei mehr durch ein kleineres Duplikat
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.
This commit is contained in:
parent
45789918b0
commit
c8ea2f6765
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user