fix(extraction): coordinate jobs leases and shutdown
Route full, hybrid, top-level nested, and deferred nested archive work through the single global coordinator and remove the package-level extraction slot layer. Build each operation reservation from deduplicated multipart members, retain it through real child close and package output-scope finalization, and preserve package/run cancellation ownership while clearing queued waiters. Delay native and JVM abort/timeout completion until child close, retain daemon requests until exit, drain extraction before session persistence, and stop the daemon last. Add focused integration, process lifecycle, lease, and shutdown ordering regression coverage.
This commit is contained in:
+66
-103
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, resolveUnrestrictTimeoutBudgetMs, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||
import { ExtractionCoordinator } from "../src/main/extraction-coordinator";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
@@ -2310,42 +2311,6 @@ describe("download manager", () => {
|
||||
expect((manager as any).shouldCollapseQuickPostProcessRequeue(packageId)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors maxParallelExtract for concurrent post-process slots", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-slots-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
maxParallelExtract: 4
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await (manager as any).acquirePostProcessSlot("pkg-1");
|
||||
await (manager as any).acquirePostProcessSlot("pkg-2");
|
||||
await (manager as any).acquirePostProcessSlot("pkg-3");
|
||||
await (manager as any).acquirePostProcessSlot("pkg-4");
|
||||
|
||||
expect((manager as any).packagePostProcessActive).toBe(4);
|
||||
|
||||
let fifthResolved = false;
|
||||
const fifth = (manager as any).acquirePostProcessSlot("pkg-5").then(() => {
|
||||
fifthResolved = true;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(fifthResolved).toBe(false);
|
||||
|
||||
(manager as any).releasePostProcessSlot();
|
||||
await fifth;
|
||||
|
||||
expect(fifthResolved).toBe(true);
|
||||
expect((manager as any).packagePostProcessActive).toBe(4);
|
||||
});
|
||||
|
||||
it("extractNow only re-arms completed items that are not already extracted", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-now-"));
|
||||
tempDirs.push(root);
|
||||
@@ -15543,6 +15508,71 @@ describe("package lifecycle telemetry boundaries", () => {
|
||||
expect(pkg.outputRecords).toEqual([expect.objectContaining({ state: "partial", outputPath: path.join(extractDir, "partial.mkv") })]);
|
||||
});
|
||||
|
||||
it("holds one deduplicated multipart lease through child close and output-scope finalization", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-coordinated-lease-"));
|
||||
tempDirs.push(root);
|
||||
const outputDir = path.join(root, "downloads");
|
||||
const extractDir = path.join(root, "extract");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const firstPart = path.join(outputDir, "release.part1.rar");
|
||||
const secondPart = path.join(outputDir, "release.part2.rar");
|
||||
fs.writeFileSync(firstPart, Buffer.alloc(100));
|
||||
fs.writeFileSync(secondPart, Buffer.alloc(200));
|
||||
const session = emptySession();
|
||||
const pkg: PackageEntry = {
|
||||
id: "coordinated-lease",
|
||||
name: "coordinated-lease",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
resultGeneration: 3,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000
|
||||
};
|
||||
session.packages[pkg.id] = pkg;
|
||||
session.packageOrder = [pkg.id];
|
||||
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
|
||||
const state = manager as any;
|
||||
state.extractionCoordinator = new ExtractionCoordinator(1);
|
||||
state.diskReservations = new DiskReservationCoordinator({
|
||||
safetyBytes: 0,
|
||||
statVolume: async () => ({ path: extractDir, volumeKey: "extract-volume", freeBytes: 10_000, totalBytes: 20_000 })
|
||||
});
|
||||
const originalSync = state.syncPackageOutputScope.bind(state);
|
||||
const reservationsDuringFinalization: number[] = [];
|
||||
state.syncPackageOutputScope = (entry: PackageEntry, scope: unknown) => {
|
||||
reservationsDuringFinalization.push(state.diskReservations.getReservedBytesByVolume().get("extract-volume") || 0);
|
||||
return originalSync(entry, scope);
|
||||
};
|
||||
let closeChild = () => {};
|
||||
const childClosed = new Promise<void>((resolve) => {
|
||||
closeChild = resolve;
|
||||
});
|
||||
let completed = false;
|
||||
|
||||
const extraction = state.runCoordinatedExtraction(
|
||||
pkg,
|
||||
[firstPart, firstPart.toUpperCase(), secondPart],
|
||||
undefined,
|
||||
async (_targetDir: string, _scope: unknown, scheduleArchive: (archivePath: string, execute: (signal: AbortSignal) => Promise<void>) => Promise<void>) => {
|
||||
await scheduleArchive(firstPart, async () => childClosed);
|
||||
}
|
||||
).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(state.diskReservations.getReservedBytesByVolume().get("extract-volume")).toBe(300));
|
||||
expect(completed).toBe(false);
|
||||
closeChild();
|
||||
await extraction;
|
||||
|
||||
expect(reservationsDuringFinalization).toContain(300);
|
||||
expect(state.diskReservations.getReservedBytesByVolume().get("extract-volume")).toBe(0);
|
||||
});
|
||||
|
||||
it("uses normalized nested item paths for archive identity and leaves empty item provenance at zero", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-"));
|
||||
tempDirs.push(root);
|
||||
@@ -15611,73 +15641,6 @@ describe("package lifecycle telemetry boundaries", () => {
|
||||
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
|
||||
});
|
||||
|
||||
it("keeps foreign post-process waiters reserved when stopping another run", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-run-owned-slots-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const createPackage = (id: string): PackageEntry => ({
|
||||
id,
|
||||
name: id,
|
||||
outputDir: path.join(root, "downloads", id),
|
||||
extractDir: path.join(root, "extract", id),
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1_000,
|
||||
updatedAt: 1_000
|
||||
});
|
||||
const packageA = createPackage("run-a-package");
|
||||
const packageB = createPackage("run-b-package");
|
||||
session.packages[packageA.id] = packageA;
|
||||
session.packages[packageB.id] = packageB;
|
||||
session.packageOrder = [packageA.id, packageB.id];
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), maxParallelExtract: 1 },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const state = manager as any;
|
||||
const runA = state.createRunContext([packageA.id], 1_000, false);
|
||||
const runB = state.beginActiveRunContext([packageB.id], 2_000);
|
||||
session.running = true;
|
||||
session.runStartedAt = 2_000;
|
||||
state.runPackageIds = new Set([packageB.id]);
|
||||
state.runItemIds = new Set(["run-b-item"]);
|
||||
let concurrent = 1;
|
||||
let peak = concurrent;
|
||||
let foreignResolved = false;
|
||||
|
||||
await state.acquirePostProcessSlot("active-a", runA.id);
|
||||
const foreignWaiter = state.acquirePostProcessSlot("waiting-a", runA.id).then((acquired: boolean | undefined) => {
|
||||
foreignResolved = true;
|
||||
if (acquired !== false) {
|
||||
concurrent += 1;
|
||||
peak = Math.max(peak, concurrent);
|
||||
}
|
||||
return acquired;
|
||||
});
|
||||
const stoppedWaiter = state.acquirePostProcessSlot("waiting-b", runB.id);
|
||||
manager.stop();
|
||||
const stoppedResult = await stoppedWaiter;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(stoppedResult).toBe(false);
|
||||
expect(foreignResolved).toBe(false);
|
||||
expect(state.packagePostProcessActive).toBe(1);
|
||||
|
||||
concurrent -= 1;
|
||||
state.releasePostProcessSlot();
|
||||
const foreignResult = await foreignWaiter;
|
||||
expect(foreignResult).toBe(true);
|
||||
expect(state.packagePostProcessActive).toBe(1);
|
||||
expect(peak).toBe(1);
|
||||
|
||||
concurrent -= 1;
|
||||
state.releasePostProcessSlot();
|
||||
expect(state.packagePostProcessActive).toBe(0);
|
||||
});
|
||||
|
||||
it("records queued, slot start and terminal timestamps around real post-processing", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -331,4 +331,35 @@ describe("ExtractionCoordinator", () => {
|
||||
await Promise.all([active, finalized, shutdown]);
|
||||
expect(events).toEqual(["waiter-cancel", "active-abort", "child-close", "scope-finalize", "lease-release"]);
|
||||
});
|
||||
|
||||
it("waits for scope finalization registered immediately after child drain", async () => {
|
||||
const coordinator = new ExtractionCoordinator(1);
|
||||
const heldLease = lease();
|
||||
const operation = await coordinator.beginOperation({
|
||||
context: context("late-finalize", "package-a", "run"),
|
||||
targetPath: "C:\\target",
|
||||
members: [{ path: "C:\\archives\\one.rar", size: 100 }],
|
||||
acquireLease: async () => heldLease
|
||||
});
|
||||
const childClose = deferred<void>();
|
||||
const scopeClose = deferred<void>();
|
||||
const active = coordinator.scheduleArchive(operation, "active", async () => childClose.promise);
|
||||
let shutdownSettled = false;
|
||||
const shutdown = coordinator.shutdownAndDrain(Date.now() + 1000).then(() => {
|
||||
shutdownSettled = true;
|
||||
});
|
||||
|
||||
childClose.resolve();
|
||||
await active;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(shutdownSettled).toBe(false);
|
||||
const finalization = operation.finalize(async () => scopeClose.promise);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(shutdownSettled).toBe(false);
|
||||
expect(heldLease.release).not.toHaveBeenCalled();
|
||||
|
||||
scopeClose.resolve();
|
||||
await Promise.all([finalization, shutdown]);
|
||||
expect(heldLease.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const childProcesses = vi.hoisted(() => {
|
||||
class FakeEmitter {
|
||||
private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
public on(name: string, listener: (...args: unknown[]) => void): this {
|
||||
const listeners = this.listeners.get(name) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(name, listeners);
|
||||
return this;
|
||||
}
|
||||
|
||||
public emit(name: string, ...args: unknown[]): boolean {
|
||||
for (const listener of this.listeners.get(name) || []) {
|
||||
listener(...args);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeChild extends FakeEmitter {
|
||||
public readonly stdout = new FakeEmitter();
|
||||
public readonly stderr = new FakeEmitter();
|
||||
public readonly stdin = { end: vi.fn(), write: vi.fn() };
|
||||
public readonly pid: number;
|
||||
public readonly kill = vi.fn();
|
||||
|
||||
public constructor(pid: number) {
|
||||
super();
|
||||
this.pid = pid;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nextPid: 10_000,
|
||||
activeExtraction: null as FakeChild | null,
|
||||
spawn: vi.fn((_command: string, args: string[]) => {
|
||||
const child = new FakeChild(childProcesses.nextPid++);
|
||||
if (args[0] === "?") {
|
||||
queueMicrotask(() => child.emit("close", 0));
|
||||
} else if (args[0] === "l") {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", "----------\nPath = episode.mkv\nFolder = -\n");
|
||||
child.emit("close", 0);
|
||||
});
|
||||
} else if (args[0] === "/PID") {
|
||||
queueMicrotask(() => child.emit("close", 0));
|
||||
} else {
|
||||
childProcesses.activeExtraction = child;
|
||||
}
|
||||
return child;
|
||||
}),
|
||||
spawnSync: vi.fn(() => ({ status: 1, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }))
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: childProcesses.spawn,
|
||||
spawnSync: childProcesses.spawnSync
|
||||
}));
|
||||
|
||||
import { extractPackageArchives } from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalSevenZip = process.env.RD_7Z_BIN;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const directory of tempDirs.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
childProcesses.activeExtraction = null;
|
||||
childProcesses.spawn.mockClear();
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.RD_EXTRACT_BACKEND;
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||
}
|
||||
if (originalSevenZip === undefined) {
|
||||
delete process.env.RD_7Z_BIN;
|
||||
} else {
|
||||
process.env.RD_7Z_BIN = originalSevenZip;
|
||||
}
|
||||
});
|
||||
|
||||
describe("extractor child close lifecycle", () => {
|
||||
it("keeps an aborted native archive job active until the original child closes", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-child-close-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "package");
|
||||
const targetDir = path.join(root, "target");
|
||||
const sevenZipPath = path.join(root, "7z.exe");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(sevenZipPath, "fake");
|
||||
fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c", "hex"));
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
process.env.RD_7Z_BIN = sevenZipPath;
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
let failure: unknown;
|
||||
|
||||
const extraction = extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: "",
|
||||
signal: controller.signal
|
||||
}).catch((error) => {
|
||||
failure = error;
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(childProcesses.activeExtraction).not.toBeNull());
|
||||
controller.abort("abort-test");
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
childProcesses.activeExtraction?.emit("close", 1);
|
||||
await extraction;
|
||||
expect(String(failure)).toContain("aborted:extract");
|
||||
});
|
||||
|
||||
it("keeps a timed-out native archive job active until the original child closes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-native-timeout-close-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "package");
|
||||
const targetDir = path.join(root, "target");
|
||||
const sevenZipPath = path.join(root, "7z.exe");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.writeFileSync(sevenZipPath, "fake");
|
||||
fs.writeFileSync(path.join(packageDir, "release.7z"), Buffer.from("377abcaf271c", "hex"));
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
process.env.RD_7Z_BIN = sevenZipPath;
|
||||
let settled = false;
|
||||
const results: Awaited<ReturnType<typeof extractPackageArchives>>[] = [];
|
||||
|
||||
const extraction = extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
passwordList: ""
|
||||
}).then((value) => {
|
||||
results.push(value);
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(childProcesses.activeExtraction).not.toBeNull());
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
childProcesses.activeExtraction?.emit("close", 1);
|
||||
await extraction;
|
||||
expect(results[0]).toEqual(expect.objectContaining({ failed: 1 }));
|
||||
expect(results[0]?.lastError).toContain("Timeout");
|
||||
});
|
||||
});
|
||||
+34
-3
@@ -1214,7 +1214,6 @@ describe("extractor", () => {
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
maxParallel: 2,
|
||||
passwordList: "pw1|pw2|pw3",
|
||||
onProgress: (update) => {
|
||||
if (update.phase !== "extracting" || !update.archiveName) return;
|
||||
@@ -1249,7 +1248,6 @@ describe("extractor", () => {
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
maxParallel: 4
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(2);
|
||||
@@ -1274,7 +1272,6 @@ describe("extractor", () => {
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
maxParallel: 4,
|
||||
passwordList: "pw1|pw2|pw3"
|
||||
});
|
||||
|
||||
@@ -1430,6 +1427,40 @@ describe("extractor", () => {
|
||||
expect(fs.existsSync(path.join(targetDir, "foreign.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("delegates top-level and nested archive jobs through one scheduler", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-global-extract-scheduler-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
const first = new AdmZip();
|
||||
first.addFile("first.txt", Buffer.from("first"));
|
||||
first.writeZip(path.join(packageDir, "first.zip"));
|
||||
const nested = new AdmZip();
|
||||
nested.addFile("nested.txt", Buffer.from("nested"));
|
||||
const second = new AdmZip();
|
||||
second.addFile("owned.zip", nested.toBuffer());
|
||||
second.writeZip(path.join(packageDir, "second.zip"));
|
||||
const scheduled: string[] = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
scheduleArchive: async (archivePath, execute) => {
|
||||
scheduled.push(path.basename(archivePath));
|
||||
return execute(new AbortController().signal);
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(0);
|
||||
expect(scheduled).toEqual(["first.zip", "second.zip", "owned.zip"]);
|
||||
expect(fs.readFileSync(path.join(targetDir, "nested.txt"), "utf8")).toBe("nested");
|
||||
});
|
||||
|
||||
it("resumes same-basename archives by relative path and invalidates changed multipart fingerprints", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-v2-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -69,9 +69,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("main shutdown lifecycle", () => {
|
||||
it("drains extraction before session persistence and runtime disposal", async () => {
|
||||
const drain = deferred();
|
||||
const events: string[] = [];
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = null;
|
||||
controller.downloadHealthEvaluation = null;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(() => events.push("queue-close")),
|
||||
shutdownAndDrain: vi.fn(async () => {
|
||||
events.push("child-drain-start");
|
||||
await drain.promise;
|
||||
events.push("child-drain-end");
|
||||
}),
|
||||
persistForShutdown: vi.fn(() => events.push("session-persist")),
|
||||
flushNotificationsForShutdown: vi.fn(async () => undefined)
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn(() => events.push("runtime-dispose")) };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
|
||||
const shutdown = controller.shutdown();
|
||||
await Promise.resolve();
|
||||
expect(events).toEqual(["queue-close", "child-drain-start"]);
|
||||
drain.resolve();
|
||||
await shutdown;
|
||||
|
||||
expect(events).toEqual([
|
||||
"queue-close",
|
||||
"child-drain-start",
|
||||
"child-drain-end",
|
||||
"session-persist",
|
||||
"runtime-dispose"
|
||||
]);
|
||||
});
|
||||
|
||||
it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
|
||||
const drain = deferred();
|
||||
const manager = { prepareForShutdown: vi.fn() };
|
||||
const manager = {
|
||||
prepareForShutdown: vi.fn(),
|
||||
shutdownAndDrain: vi.fn(async () => undefined),
|
||||
persistForShutdown: vi.fn()
|
||||
};
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(() => drain.promise) };
|
||||
@@ -88,6 +136,7 @@ describe("main shutdown lifecycle", () => {
|
||||
const shutdown = controller.shutdown();
|
||||
|
||||
expect(shutdown).toBeInstanceOf(Promise);
|
||||
await vi.waitFor(() => expect(controller.notificationOutbox.drainForShutdown).toHaveBeenCalledTimes(1));
|
||||
const drainBudget = controller.notificationOutbox.drainForShutdown.mock.calls[0][0];
|
||||
expect(drainBudget).toBeGreaterThan(0);
|
||||
expect(drainBudget).toBeLessThanOrEqual(3000);
|
||||
|
||||
Reference in New Issue
Block a user