Fix disk-fallback in hybrid extract allowing partial files

Two bugs in findReadyArchiveSets disk-fallback:

1. Failed items were explicitly excluded from the blocking check
   (status !== "failed"), so partial downloads with "failed" status
   would not block extraction. Now ANY non-completed item blocks.

2. The disk size check was only > 10 KB, allowing 627 MB partial
   files of 1001 MB archives to pass. Now requires the file to be
   within one allocation unit of the item's expected totalBytes.

Added findItemByDiskPath helper to look up the owning item for a
file on disk and get its expected size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-07 20:10:04 +01:00
parent 7d09479b44
commit e80948df54

View File

@ -6926,33 +6926,38 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
// Disk-fallback: if all parts exist on disk but some items lack "completed" status, // Disk-fallback: if all parts exist on disk at their full expected size but some
// allow extraction if none of those parts are actively downloading/validating. // items lack "completed" status, allow extraction. This handles items that finished
// This handles items that finished downloading but whose status was not updated. // downloading but whose status was not updated (crash between write and persist).
const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part))); const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part)));
let allMissingExistOnDisk = true; let allMissingFullOnDisk = true;
for (const part of missingParts) { for (const part of missingParts) {
try { try {
const stat = await fs.promises.stat(part); const stat = await fs.promises.stat(part);
if (stat.size < 10240) { // Find the item that owns this file to get its expected totalBytes
allMissingExistOnDisk = false; const ownerItem = this.findItemByDiskPath(pkg, part);
const minBytes = ownerItem?.totalBytes && ownerItem.totalBytes > 0
? ownerItem.totalBytes - ALLOCATION_UNIT_SIZE
: 10240;
if (stat.size < minBytes) {
allMissingFullOnDisk = false;
break; break;
} }
} catch { } catch {
allMissingExistOnDisk = false; allMissingFullOnDisk = false;
break; break;
} }
} }
if (!allMissingExistOnDisk) { if (!allMissingFullOnDisk) {
continue; continue;
} }
// Any non-completed item blocks extraction — cancelled/stopped items may // Any non-completed item blocks extraction — failed/cancelled/stopped items may
// have partial files on disk that would corrupt the extraction. // have partial files on disk that would corrupt the extraction.
const anyActivelyProcessing = missingParts.some((part) => { const anyNonCompletedItem = missingParts.some((part) => {
const status = pendingItemStatus.get(pathKey(part)); const status = pendingItemStatus.get(pathKey(part));
return status !== undefined && status !== "failed"; return status !== undefined;
}); });
if (anyActivelyProcessing) { if (anyNonCompletedItem) {
continue; continue;
} }
// Safety: if any pending item in the package has neither targetPath nor fileName, // Safety: if any pending item in the package has neither targetPath nor fileName,
@ -6975,6 +6980,17 @@ export class DownloadManager extends EventEmitter {
return ready; return ready;
} }
private findItemByDiskPath(pkg: PackageEntry, diskPath: string): DownloadItem | undefined {
const key = pathKey(diskPath);
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) continue;
if (item.targetPath && pathKey(item.targetPath) === key) return item;
if (item.fileName && pkg.outputDir && pathKey(path.join(pkg.outputDir, item.fileName)) === key) return item;
}
return undefined;
}
private looksLikeArchivePart(fileName: string, entryPointName: string): boolean { private looksLikeArchivePart(fileName: string, entryPointName: string): boolean {
const multipartMatch = entryPointName.match(/^(.*)\.part0*1\.rar$/i); const multipartMatch = entryPointName.match(/^(.*)\.part0*1\.rar$/i);
if (multipartMatch) { if (multipartMatch) {