fix(extraction): close final scope integration gaps

Ignore late JVM output events after abort or timeout until the real child close reconciles opened files. Restrict deferred archive cleanup to package-owned item paths so shared download roots retain foreign archives while package-local empty directory cleanup still works. Update deferred ownership and output-scope fixtures to the coordinator contract.
This commit is contained in:
Sucukdeluxe
2026-08-22 17:29:03 +02:00
parent 3e18ec57ea
commit 522f53d787
4 changed files with 231 additions and 118 deletions
+118 -21
View File
@@ -43,6 +43,19 @@ function writePackageOutputOwnerMarker(pkg: PackageEntry): void {
}));
}
function setExtractOutputRecords(pkg: PackageEntry, outputPaths: string[]): void {
pkg.outputProvenanceVersion = 1;
pkg.outputRecords = outputPaths.map((outputPath) => ({
version: 1,
archivePath: path.join(pkg.outputDir, "source.zip"),
entryPath: path.relative(pkg.extractDir, outputPath).replace(/\\/g, "/"),
outputPath,
state: "complete",
disposition: "written"
}));
pkg.outputCount = outputPaths.length;
}
describe("runWithLimitedConcurrency", () => {
it("processes the full batch without exceeding the configured worker count", async () => {
let active = 0;
@@ -12015,7 +12028,7 @@ describe("download manager", () => {
expect(remainingPackage?.outputDir).toBe(outputDir);
});
it("removes link and sample artifacts from extracted output even when deferred post-processing has failures", async () => {
it("removes link and sample artifacts from extracted output even when deferred post-processing has failures", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -12060,12 +12073,16 @@ describe("download manager", () => {
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Done (1s)",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
fullStatus: "Entpackt - Done (1s)",
createdAt,
updatedAt: createdAt
};
setExtractOutputRecords(session.packages[packageId], [
path.join(extractDir, "episode.links.txt"),
path.join(extractDir, "sample", "sample.mkv")
]);
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -12093,10 +12110,87 @@ describe("download manager", () => {
);
expect(fs.existsSync(path.join(extractDir, "episode.links.txt"))).toBe(false);
expect(fs.existsSync(path.join(extractDir, "sample", "sample.mkv"))).toBe(false);
});
it("does not delete startup archives when any completed item has an extract error", async () => {
expect(fs.existsSync(path.join(extractDir, "sample", "sample.mkv"))).toBe(false);
});
it("deletes only package-owned archive members from a shared download root", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shared-cleanup-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads");
const extractDir = path.join(root, "extract", "owned-package");
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
const ownedPaths = [path.join(outputDir, "owned.part1.rar"), path.join(outputDir, "owned.part2.rar")];
const foreignPaths = [path.join(outputDir, "foreign.part1.rar"), path.join(outputDir, "foreign.part2.rar")];
for (const archivePath of [...ownedPaths, ...foreignPaths]) {
fs.writeFileSync(archivePath, archivePath);
}
const extractedPath = path.join(extractDir, "episode.mkv");
fs.writeFileSync(extractedPath, "video");
const session = emptySession();
const packageId = "owned-package";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: packageId,
outputDir,
extractDir,
status: "completed",
itemIds: ["owned-part-1", "owned-part-2"],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (let index = 0; index < ownedPaths.length; index += 1) {
const itemId = `owned-part-${index + 1}`;
session.items[itemId] = {
id: itemId,
packageId,
url: `https://example.com/${path.basename(ownedPaths[index])}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: fs.statSync(ownedPaths[index]).size,
totalBytes: fs.statSync(ownedPaths[index]).size,
progressPercent: 100,
fileName: path.basename(ownedPaths[index]),
targetPath: ownedPaths[index],
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpackt - Done (1s)",
createdAt,
updatedAt: createdAt
};
}
setExtractOutputRecords(session.packages[packageId], [extractedPath]);
const manager = new DownloadManager(
{
...defaultSettings(),
outputDir,
extractDir: path.join(root, "extract"),
autoExtract: true,
autoRename4sf4sj: false,
collectMkvToLibrary: false,
removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false,
enableIntegrityCheck: false,
cleanupMode: "delete"
},
session,
createStoragePaths(path.join(root, "state"))
);
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
expect(ownedPaths.map((archivePath) => fs.existsSync(archivePath))).toEqual([false, false]);
expect(foreignPaths.map((archivePath) => fs.existsSync(archivePath))).toEqual([true, true]);
});
it("does not delete startup archives when any completed item has an extract error", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
@@ -13087,14 +13181,16 @@ describe("download manager", () => {
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
);
(manager as any).fileStabilizeMinAgeMs = 30_000;
setExtractOutputRecords(session.packages[packageId], [scenePath]);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
const sceneLibPath = path.join(mkvLibraryDir, sceneName);
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
const outputScope = (manager as any).getPackageOutputScope(session.packages[packageId]);
await (manager as any).autoRenameExtractedVideoFiles(extractDir, outputScope, session.packages[packageId], undefined, true);
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
expect(fs.existsSync(renamedLibPath)).toBe(true);
@@ -13149,11 +13245,12 @@ describe("download manager", () => {
},
session,
createStoragePaths(path.join(root, "state"))
);
(manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
);
(manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
setExtractOutputRecords(session.packages[packageId], [path.join(epFolder, sceneName)]);
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, sceneName))).toBe(false);
+17 -3
View File
@@ -789,7 +789,21 @@ describe("authoritative run completion", () => {
packageAItem.fullStatus = "Fertig";
packageA.status = "completed";
state.runOutcomes.set(packageAItem.id, "completed");
state.packagePostProcessActive = 1;
const handlePackagePostProcessing = state.handlePackagePostProcessing.bind(state);
let releaseMainPostProcess = (): void => {};
let markMainPostProcessEntered = (): void => {};
const mainPostProcessGate = new Promise<void>((resolve) => {
releaseMainPostProcess = resolve;
});
const mainPostProcessEntered = new Promise<void>((resolve) => {
markMainPostProcessEntered = resolve;
});
vi.spyOn(state, "handlePackagePostProcessing").mockImplementation(async (...args: unknown[]) => {
const [packageId, signal] = args as [string, AbortSignal?];
markMainPostProcessEntered();
await mainPostProcessGate;
await handlePackagePostProcessing(packageId, signal);
});
let releaseCollection = (): void => {};
const collectionGate = new Promise<void>((resolve) => {
@@ -797,14 +811,14 @@ describe("authoritative run completion", () => {
});
const collect = vi.spyOn(state, "collectMkvFilesToLibrary").mockImplementation(async () => collectionGate);
const packageAMainPostProcess = state.runPackagePostProcessing(packageA.id);
await vi.waitFor(() => expect(state.packagePostProcessWaiters).toHaveLength(1));
await mainPostProcessEntered;
state.finishRun();
const packageB = addPackage(session, ["queued"], "active-run-package");
await manager.start();
expect(state.runPackageIds).toEqual(new Set([packageB.id]));
state.releasePostProcessSlot();
releaseMainPostProcess();
await packageAMainPostProcess;
await vi.waitFor(() => expect(collect).toHaveBeenCalled());
const deferredTasks = [...(state.packageDeferredPostProcessTasks.get(packageA.id) || [])];