From 650dafb535561478fde437c05a14854e2980296d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 29 Mar 2026 03:25:58 +0200 Subject: [PATCH] Fix support bundle export freeze and resume prealloc recovery --- src/main/app-controller.ts | 18 +- src/main/download-manager.ts | 145 ++++++++--- src/main/main.ts | 26 +- src/main/support-bundle.ts | 45 +++- src/main/windows-host-diagnostics.ts | 4 + tests/download-manager.test.ts | 356 +++++++++++++++++++++++++++ 6 files changed, 533 insertions(+), 61 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index ad72241..c29ca84 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -557,17 +557,21 @@ export class AppController { return encryptBackup(payload); } - public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { - this.audit("INFO", "Support-Bundle exportiert"); + public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { + this.audit("INFO", "Support-Bundle exportiert"); logTraceEvent("INFO", "support", "Support-Bundle erstellt", { packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, itemCount: Object.keys(this.manager.getSnapshot().session.items).length }); - return { - buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir), - defaultFileName: getSupportBundleDefaultFileName() - }; - } + return { + buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), + defaultFileName: getSupportBundleDefaultFileName() + }; + } + + public getSupportBundleDefaultFileName(): string { + return getSupportBundleDefaultFileName(); + } public importBackup(data: Buffer): { restored: boolean; message: string } { let parsed: Record; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 98128b0..053a032 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -126,7 +126,9 @@ const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; const RESUME_REWIND_BYTES = 256 * 1024; const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; - + +const PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES = 1024 * 1024; + const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; function expectedMinBytes(totalBytes: number | null | undefined, strict: boolean): number { @@ -140,6 +142,12 @@ function itemExpectedMinBytes(item: DownloadItem): number { const strict = isLargeBinaryLikePath(item.targetPath || item.fileName || ""); return expectedMinBytes(item.totalBytes, strict); } + +function resolvePreallocResumeMismatchThreshold(pathHint: string): number { + return isLargeBinaryLikePath(pathHint) + ? 0 + : PREALLOC_RESUME_MISMATCH_THRESHOLD_BYTES; +} function resolvePackageItemDiskPath(pkg: PackageEntry, item: DownloadItem): string | null { if (item.targetPath) { @@ -8479,19 +8487,41 @@ export class DownloadManager extends EventEmitter { } else if (resumeRewindBytesNextAttempt > 0) { resumeRewindBytesNextAttempt = 0; } + const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); + const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || effectiveTargetPath || ""); // Guard against pre-allocated sparse files from a crashed session: - // if file size exceeds persisted downloadedBytes by >1MB, the file was - // likely pre-allocated but only partially written before a hard crash. - if (existingBytes > 0 && item.downloadedBytes > 0 && existingBytes > item.downloadedBytes + 1048576) { + // if file size exceeds persisted downloadedBytes beyond the allowed + // mismatch threshold, the file was likely pre-allocated but only + // partially written before a hard crash. + // This must also run for persistedBytes=0, otherwise startup-resume can + // send Range=full-size and incorrectly accept HTTP 416 as "complete". + if (existingBytes > 0 && existingBytes > persistedBytes + preallocMismatchThreshold) { try { - await fs.promises.truncate(effectiveTargetPath, item.downloadedBytes); - existingBytes = item.downloadedBytes; - } catch { /* best-effort */ } - } - const headers: Record = {}; - if (existingBytes > 0) { - headers.Range = `bytes=${existingBytes}-`; - } + const previousBytes = existingBytes; + await fs.promises.truncate(effectiveTargetPath, persistedBytes); + existingBytes = persistedBytes; + logAttemptEvent("WARN", "Pre-alloc-Rest erkannt, Teil-Datei auf persistierte Bytes gekuerzt", { + attempt, + previousBytes, + persistedBytes + }); + } catch { + if (persistedBytes === 0) { + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + existingBytes = 0; + } catch { + // ignore + } + } + } + } + const suspiciousResumeFootprint = existingBytes > 0 + && existingBytes > persistedBytes + preallocMismatchThreshold; + const headers: Record = {}; + if (existingBytes > 0) { + headers.Range = `bytes=${existingBytes}-`; + } logAttemptEvent("INFO", "HTTP-Download-Versuch vorbereitet", { attempt, maxAttempts: maxAttempts === Number.MAX_SAFE_INTEGER ? "infinite" : maxAttempts, @@ -8565,23 +8595,31 @@ export class DownloadManager extends EventEmitter { const sizeToleranceBytes = isLargeBinaryLikePath(item.fileName || effectiveTargetPath) ? 0 : ALLOCATION_UNIT_SIZE; const closeEnoughToExpected = expectedTotal != null && Math.abs(existingBytes - expectedTotal) <= sizeToleranceBytes; - if (expectedTotal != null && closeEnoughToExpected) { - const finalizedTotal = Math.max(existingBytes, expectedTotal); - item.totalBytes = finalizedTotal; - item.downloadedBytes = existingBytes; - item.progressPercent = 100; - item.speedBps = 0; + if (expectedTotal != null && closeEnoughToExpected && !suspiciousResumeFootprint) { + const finalizedTotal = Math.max(existingBytes, expectedTotal); + item.totalBytes = finalizedTotal; + item.downloadedBytes = existingBytes; + item.progressPercent = 100; + item.speedBps = 0; item.updatedAt = nowMs(); logAttemptEvent("INFO", "HTTP 416 als vollständig behandelt", { existingBytes, - expectedTotal: finalizedTotal - }); - return { resumable: true }; - } - - try { - await fs.promises.rm(effectiveTargetPath, { force: true }); - } catch { + expectedTotal: finalizedTotal + }); + return { resumable: true }; + } + if (expectedTotal != null && closeEnoughToExpected && suspiciousResumeFootprint) { + logAttemptEvent("WARN", "HTTP 416 trotz Vollgroesse nicht als fertig gewertet (vermutlich pre-alloc)", { + attempt, + existingBytes, + persistedBytes, + expectedTotal + }); + } + + try { + await fs.promises.rm(effectiveTargetPath, { force: true }); + } catch { // ignore } this.dropItemContribution(active.itemId); @@ -10459,19 +10497,50 @@ export class DownloadManager extends EventEmitter { } try { const stat = await fs.promises.stat(item.targetPath); - // Require file to be essentially complete — within one allocation unit of the - // expected size. The old 50% threshold incorrectly recovered partial downloads - // (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives. + // Require file to be essentially complete — within one allocation unit of the + // expected size. The old 50% threshold incorrectly recovered partial downloads + // (e.g. 627 MB of 1001 MB) and triggered hybrid extraction on incomplete archives. const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath)); - if (stat.size >= minSize) { - // Re-check: another task may have started this item during the await - const latestItem = this.session.items[item.id]; - if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading" - || latestItem.status === "validating" || latestItem.status === "integrity_check") { - continue; - } - // Guard against pre-allocated sparse files from a hard crash: file has - // the full expected size but downloadedBytes is significantly behind. + const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0)); + const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || item.targetPath || ""); + const suspiciousPreallocFootprint = item.totalBytes != null + && item.totalBytes > 0 + && stat.size >= minSize + && stat.size > persistedBytes + preallocMismatchThreshold; + if (stat.size >= minSize) { + // Re-check: another task may have started this item during the await + const latestItem = this.session.items[item.id]; + if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading" + || latestItem.status === "validating" || latestItem.status === "integrity_check") { + continue; + } + if (suspiciousPreallocFootprint) { + logger.warn( + `Item-Recovery: ${item.fileName} uebersprungen – pre-alloc-Verdacht ` + + `(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(item.totalBytes)})` + ); + try { + if (persistedBytes > 0) { + fs.truncateSync(item.targetPath, persistedBytes); + } else { + fs.rmSync(item.targetPath, { force: true }); + } + } catch { + // best-effort + } + item.status = "queued"; + item.attempts = 0; + item.downloadedBytes = persistedBytes; + item.progressPercent = item.totalBytes > 0 + ? Math.max(0, Math.min(99, Math.floor((persistedBytes / item.totalBytes) * 100))) + : 0; + item.speedBps = 0; + item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)"; + item.updatedAt = nowMs(); + continue; + } + // Guard against pre-allocated sparse files from a hard crash: file has + // the full expected size but downloadedBytes is significantly behind. if (item.downloadedBytes > 0 && item.totalBytes && item.totalBytes > 0 && stat.size >= minSize && item.downloadedBytes < item.totalBytes * 0.95) { diff --git a/src/main/main.ts b/src/main/main.ts index 9a23ea4..e834609 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -524,19 +524,19 @@ function registerIpcHandlers(): void { return { saved: true }; }); - ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { - const exported = controller.exportSupportBundle(); - const options = { - defaultPath: exported.defaultFileName, - filters: [{ name: "Support Bundle", extensions: ["zip"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false }; - } - await fs.promises.writeFile(result.filePath, exported.buffer); - return { saved: true, filePath: result.filePath }; - }); + ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + const options = { + defaultPath: controller.getSupportBundleDefaultFileName(), + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const exported = controller.exportSupportBundle(); + await fs.promises.writeFile(result.filePath, exported.buffer); + return { saved: true, filePath: result.filePath }; + }); ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { const logPath = getLogFilePath(); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index eead79c..ee153ed 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -11,7 +11,7 @@ import { getSessionLogPath } from "./session-log"; import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; -import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; +import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import type { DownloadManager } from "./download-manager"; const AI_MANIFEST_FILE = "debug_ai_manifest.json"; @@ -65,8 +65,47 @@ export function getSupportBundleDefaultFileName(): string { return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; } -export function buildSupportBundle(manager: DownloadManager, baseDir: string): Buffer { +type HostDiagnosticsMode = "full" | "cached" | "none"; + +interface BuildSupportBundleOptions { + hostDiagnosticsMode?: HostDiagnosticsMode; +} + +function createDeferredHostDiagnostics(reason: string): unknown { + return { + collectedAt: new Date().toISOString(), + supported: process.platform === "win32", + platform: process.platform, + crashControl: null, + recentKernelPower: [], + recentWerKernel: [], + recentKernelDump: [], + recentAppCrashes: [], + recentMinidumps: [], + assessmentHints: [ + reason + ], + errors: [] + }; +} + +function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown { + if (mode === "none") { + return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert."); + } + if (mode === "cached") { + const cached = getCachedWindowsHostDiagnostics(); + if (cached) { + return cached; + } + return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen."); + } + return getWindowsHostDiagnostics(); +} + +export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer { const zip = new AdmZip(); + const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; const storagePaths = createStoragePaths(baseDir); const settings = loadSettings(storagePaths); const history = loadHistory(storagePaths); @@ -107,7 +146,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B count: itemIds.length, items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean) }); - addJson(zip, "overview/host-diagnostics.json", getWindowsHostDiagnostics()); + addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); addJson(zip, "overview/trace-config.json", getTraceConfig()); addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); diff --git a/src/main/windows-host-diagnostics.ts b/src/main/windows-host-diagnostics.ts index c8883e2..7801532 100644 --- a/src/main/windows-host-diagnostics.ts +++ b/src/main/windows-host-diagnostics.ts @@ -302,6 +302,10 @@ export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiag } } +export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null { + return cachedValue; +} + export function resetWindowsHostDiagnosticsCache(): void { cachedAt = 0; cachedValue = null; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 45d1c6d..7775417 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -4205,6 +4205,76 @@ describe("download manager", () => { expect(snapshot.session.packages[packageId]?.status).toBe("queued"); }); + it("does not recover queued pre-allocated archive leftovers as completed during post-processing", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "postproc-prealloc-pkg"; + const itemId = "postproc-prealloc-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "postproc-prealloc"); + const targetPath = path.join(outputDir, "postproc-prealloc.part01.rar"); + const totalBytes = 2 * 1024 * 1024; + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(totalBytes, 0)); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "postproc-prealloc", + outputDir, + extractDir: path.join(root, "extract", "postproc-prealloc"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/postproc-prealloc", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes, + progressPercent: 0, + fileName: "postproc-prealloc.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await (manager as any).handlePackagePostProcessing(packageId); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toContain("pre-alloc"); + expect(item?.downloadedBytes).toBe(0); + expect(item?.progressPercent).toBe(0); + expect(fs.existsSync(targetPath)).toBe(false); + }); + it("requeues completed archive parts after auto-recovery extraction failures", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -7718,6 +7788,292 @@ describe("download manager", () => { } }); + it("does not mark pre-allocated crash leftovers as 100% complete on resume", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = crypto.randomBytes(2 * 1024 * 1024); + const outputDir = path.join(root, "downloads", "resume-prealloc"); + const targetPath = path.join(outputDir, "resume-prealloc.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, Buffer.alloc(binary.length, 0)); + + const session = emptySession(); + const packageId = "resume-prealloc-pkg"; + const itemId = "resume-prealloc-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-prealloc", + outputDir, + extractDir: path.join(root, "extract", "resume-prealloc"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-prealloc", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: 0, + totalBytes: binary.length, + progressPercent: 0, + fileName: "resume-prealloc.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const rangeStarts: number[] = []; + let sawRangeAtFullSize = false; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume-prealloc") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const rangeHeader = String(req.headers.range || ""); + const match = /bytes=(\d+)-/i.exec(rangeHeader); + if (match) { + const start = Number(match[1] || 0); + rangeStarts.push(start); + if (start >= binary.length) { + sawRangeAtFullSize = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(); + return; + } + const end = binary.length - 1; + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`); + res.setHeader("Content-Length", String(binary.length - start)); + res.end(binary.subarray(start)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-prealloc.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.progressPercent).toBe(100); + expect(sawRangeAtFullSize).toBe(false); + expect(rangeStarts).not.toContain(binary.length); + expect(fs.readFileSync(targetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("resumes archive tails when persisted bytes lag slightly behind pre-allocated file size", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = crypto.randomBytes(2 * 1024 * 1024); + const persistedBytes = binary.length - (256 * 1024); + const outputDir = path.join(root, "downloads", "resume-prealloc-tail"); + const targetPath = path.join(outputDir, "resume-prealloc-tail.part01.rar"); + fs.mkdirSync(outputDir, { recursive: true }); + const preallocated = Buffer.concat([ + binary.subarray(0, persistedBytes), + Buffer.alloc(binary.length - persistedBytes, 0) + ]); + fs.writeFileSync(targetPath, preallocated); + + const session = emptySession(); + const packageId = "resume-prealloc-tail-pkg"; + const itemId = "resume-prealloc-tail-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "resume-prealloc-tail", + outputDir, + extractDir: path.join(root, "extract", "resume-prealloc-tail"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/resume-prealloc-tail", + provider: "realdebrid", + status: "queued", + retries: 0, + speedBps: 0, + downloadedBytes: persistedBytes, + totalBytes: binary.length, + progressPercent: Math.floor((persistedBytes / binary.length) * 100), + fileName: "resume-prealloc-tail.part01.rar", + targetPath, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Wartet", + createdAt, + updatedAt: createdAt + }; + + const rangeStarts: number[] = []; + let sawRangeAtFullSize = false; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/resume-prealloc-tail") { + res.statusCode = 404; + res.end("not-found"); + return; + } + + const rangeHeader = String(req.headers.range || ""); + const match = /bytes=(\d+)-/i.exec(rangeHeader); + if (match) { + const start = Number(match[1] || 0); + rangeStarts.push(start); + if (start >= binary.length) { + sawRangeAtFullSize = true; + res.statusCode = 416; + res.setHeader("Content-Range", `bytes */${binary.length}`); + res.end(); + return; + } + const end = binary.length - 1; + res.statusCode = 206; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Range", `bytes ${start}-${end}/${binary.length}`); + res.setHeader("Content-Length", String(binary.length - start)); + res.end(binary.subarray(start)); + return; + } + + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/resume-prealloc-tail`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "resume-prealloc-tail.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.progressPercent).toBe(100); + expect(sawRangeAtFullSize).toBe(false); + expect(rangeStarts).not.toContain(binary.length); + expect(rangeStarts.some((start) => start === persistedBytes)).toBe(true); + expect(fs.readFileSync(targetPath).equals(binary)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("marks extracting items as resumable extraction on shutdown", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);