diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 7f0f249..9ef5dda 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -9032,6 +9032,15 @@ export class DownloadManager extends EventEmitter { return; } catch (error) { if (this.session.items[item.id] !== item) { + if (active.abortReason === "cancel") { + const orphanClaimedPath = this.claimedTargetPathByItem.get(item.id) || item.targetPath || ""; + if (orphanClaimedPath) { + try { + fs.rmSync(orphanClaimedPath, { force: true }); + } catch { + } + } + } return; } const reason = active.abortReason; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 17f60f5..1f408f3 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -865,6 +865,89 @@ describe("download manager", () => { } }); + it("deletes the orphaned partial file when a downloading item is removed mid-stream", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(512 * 1024, 7); + + let destroyHeld: () => void = () => {}; + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/direct") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, 64 * 1024)); + destroyHeld = () => { + try { res.socket?.destroy(); } catch { } + }; + }); + + 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}/direct`; + + 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: "held.mkv", 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, + autoReconnect: false, + retryLimit: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "held", links: ["https://dummy/held"] }]); + await manager.start(); + + let targetPath = ""; + await waitFor(() => { + const it = Object.values(manager.getSnapshot().session.items)[0]; + if (it && it.status === "downloading" && it.targetPath && fs.existsSync(it.targetPath)) { + targetPath = it.targetPath; + return true; + } + return false; + }, 20000); + + const itemId = Object.values(manager.getSnapshot().session.items)[0].id; + expect(fs.existsSync(targetPath)).toBe(true); + + manager.removeItem(itemId); + destroyHeld(); + + await waitFor(() => !fs.existsSync(targetPath), 20000); + expect(fs.existsSync(targetPath)).toBe(false); + } finally { + destroyHeld(); + server.close(); + await once(server, "close"); + } + }); + it("rewinds resumed range after terminated streams so corrupted tail bytes are replaced", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);