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.
399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const electron = vi.hoisted(() => {
|
|
const handlers = new Map<string, (...args: unknown[]) => void>();
|
|
return {
|
|
handlers,
|
|
app: {
|
|
isPackaged: false,
|
|
getPath: vi.fn(() => "C:\\MDD\\Test"),
|
|
getAppPath: vi.fn(() => "C:\\MDD\\App"),
|
|
requestSingleInstanceLock: vi.fn(() => true),
|
|
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => { handlers.set(name, handler); }),
|
|
whenReady: vi.fn(() => new Promise<void>(() => {})),
|
|
quit: vi.fn(),
|
|
exit: vi.fn(),
|
|
setPath: vi.fn()
|
|
}
|
|
};
|
|
});
|
|
|
|
vi.mock("electron", () => ({
|
|
app: electron.app,
|
|
BrowserWindow: class {
|
|
public static getAllWindows(): unknown[] { return []; }
|
|
},
|
|
clipboard: {},
|
|
dialog: {},
|
|
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
|
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
|
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
|
shell: { openExternal: vi.fn() },
|
|
Tray: class {}
|
|
}));
|
|
|
|
import { AppController } from "../src/main/app-controller";
|
|
import {
|
|
DownloadHealthMonitor,
|
|
createDownloadHealthState,
|
|
loadDownloadHealthState,
|
|
saveDownloadHealthState,
|
|
type DownloadHealthSnapshot
|
|
} from "../src/main/download-health-monitor";
|
|
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
|
|
|
|
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
let resolve = () => {};
|
|
const promise = new Promise<void>((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function shutdownEvent(id: string, priority: "success" | "error" = "success"): NotificationEvent {
|
|
return {
|
|
id,
|
|
type: "package_completed",
|
|
priority,
|
|
createdAt: Date.now(),
|
|
expiresAt: Date.now() + 6 * 60 * 60 * 1000,
|
|
attempts: 0,
|
|
nextAttemptAt: Date.now(),
|
|
payload: { title: "Paket-Digest", fields: [] }
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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(),
|
|
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) };
|
|
controller.manager = manager;
|
|
controller.megaWebFallback = { dispose: vi.fn() };
|
|
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();
|
|
|
|
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);
|
|
expect(manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
|
drain.resolve();
|
|
await shutdown;
|
|
});
|
|
|
|
it("uses one three-second deadline even when a running health evaluation never settles", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1000);
|
|
const runningEvaluation = deferred();
|
|
const controller = Object.create(AppController.prototype) as any;
|
|
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
|
|
controller.downloadHealthEvaluation = runningEvaluation.promise;
|
|
controller.downloadHealthMonitor = null;
|
|
controller.runtimeStatsTimer = null;
|
|
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
|
|
controller.manager = {
|
|
suspendDownloadHealthMonitoring: vi.fn(),
|
|
prepareForShutdown: vi.fn(),
|
|
flushNotificationsForShutdown: vi.fn(async () => undefined)
|
|
};
|
|
controller.megaWebFallback = { dispose: vi.fn() };
|
|
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" };
|
|
let completed = false;
|
|
|
|
const shutdown = controller.shutdown().then(() => { completed = true; });
|
|
await vi.advanceTimersByTimeAsync(2999);
|
|
expect(completed).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const completedAtDeadline = completed;
|
|
runningEvaluation.resolve();
|
|
await vi.runAllTimersAsync();
|
|
await shutdown;
|
|
|
|
expect(completedAtDeadline).toBe(true);
|
|
expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("persists a digest completed inside the shared shutdown window while an earlier send is blocked", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(1000);
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shutdown-outbox-"));
|
|
const filePath = path.join(root, "outbox.json");
|
|
let releaseSend = (_sent: boolean) => {};
|
|
let markSendStarted = () => {};
|
|
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
|
const blockedSend = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
|
const lateEnqueued = deferred();
|
|
const outbox = new NotificationOutbox({
|
|
filePath,
|
|
now: Date.now,
|
|
send: async (event) => {
|
|
if (event.id === "already-sending") {
|
|
markSendStarted();
|
|
return blockedSend;
|
|
}
|
|
return true;
|
|
}
|
|
});
|
|
await outbox.enqueue(shutdownEvent("already-sending", "error"));
|
|
const activeDrain = outbox.drain();
|
|
await sendStarted;
|
|
const controller = Object.create(AppController.prototype) as any;
|
|
controller.downloadHealthTimer = null;
|
|
controller.downloadHealthEvaluation = null;
|
|
controller.downloadHealthMonitor = null;
|
|
controller.runtimeStatsTimer = null;
|
|
controller.notificationOutbox = outbox;
|
|
controller.manager = {
|
|
suspendDownloadHealthMonitoring: vi.fn(),
|
|
prepareForShutdown: vi.fn(),
|
|
flushNotificationsForShutdown: vi.fn(() => new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
void outbox.enqueue(shutdownEvent("late-digest")).then(() => {
|
|
lateEnqueued.resolve();
|
|
resolve();
|
|
});
|
|
}, 500);
|
|
}))
|
|
};
|
|
controller.megaWebFallback = { dispose: vi.fn() };
|
|
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" };
|
|
let completed = false;
|
|
|
|
const shutdown = controller.shutdown().then(() => { completed = true; });
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await lateEnqueued.promise;
|
|
const stateDuringWindow = JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[] };
|
|
await vi.advanceTimersByTimeAsync(2500);
|
|
const completedAtDeadline = completed;
|
|
releaseSend(true);
|
|
await vi.runAllTimersAsync();
|
|
await activeDrain;
|
|
await shutdown;
|
|
|
|
expect(stateDuringWindow.events.map((event) => event.id)).toContain("late-digest");
|
|
expect(completedAtDeadline).toBe(true);
|
|
expect(controller.manager.prepareForShutdown.mock.invocationCallOrder[0])
|
|
.toBeLessThan(controller.manager.flushNotificationsForShutdown.mock.invocationCallOrder[0]);
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("prevents quit once, waits for shutdown, then allows exactly one loop-free quit", async () => {
|
|
const main = await import("../src/main/main");
|
|
const shutdown = deferred();
|
|
const cleanup = vi.fn();
|
|
const continueQuit = vi.fn();
|
|
const onError = vi.fn();
|
|
const handler = main.createBeforeQuitHandler({
|
|
cleanup,
|
|
shutdown: vi.fn(() => shutdown.promise),
|
|
continueQuit,
|
|
onError
|
|
});
|
|
const first = { preventDefault: vi.fn() };
|
|
const repeated = { preventDefault: vi.fn() };
|
|
const resumed = { preventDefault: vi.fn() };
|
|
|
|
handler(first);
|
|
handler(repeated);
|
|
expect(first.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(repeated.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(continueQuit).not.toHaveBeenCalled();
|
|
|
|
shutdown.resolve();
|
|
await vi.waitFor(() => expect(continueQuit).toHaveBeenCalledTimes(1));
|
|
handler(resumed);
|
|
expect(resumed.preventDefault).not.toHaveBeenCalled();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("waits for a running health sample and persistently closes an alerted incident before shutdown returns", async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-shutdown-"));
|
|
try {
|
|
const filePath = path.join(root, "health.json");
|
|
const runFingerprint = "a".repeat(64);
|
|
const queueFingerprint = "b".repeat(64);
|
|
saveDownloadHealthState(filePath, createDownloadHealthState({
|
|
status: "alerted",
|
|
runFingerprint,
|
|
queueFingerprint,
|
|
suspiciousDurationMs: 90_000,
|
|
suspiciousSamples: 3,
|
|
incidentStartedAt: 10_000,
|
|
alertedAt: 90_000,
|
|
lastAlertAt: 90_000,
|
|
cooldownUntil: 690_000
|
|
}));
|
|
let shuttingDown = false;
|
|
const healthSnapshot = (completionSequence: number): DownloadHealthSnapshot => ({
|
|
runActive: true,
|
|
runFingerprint,
|
|
queueFingerprint,
|
|
openItems: 1,
|
|
openPackages: 1,
|
|
knownDownloadedBytes: 4096,
|
|
activeTasks: 1,
|
|
startableItems: 0,
|
|
lastSchedulerTickAt: 100_000,
|
|
downloadProgressSequence: 0,
|
|
itemCompletionSequence: completionSequence,
|
|
lastPositiveByteAt: 0,
|
|
technicalRecoveryCount: 0,
|
|
paused: false,
|
|
reconnectUntil: 0,
|
|
nextRetryAt: 0,
|
|
providerCooldownUntil: 0,
|
|
blockedOnDisk: false,
|
|
blockedOnThrottleUntil: 0,
|
|
activePhaseDeadlineAt: 0,
|
|
terminalFailure: false,
|
|
manualStop: false,
|
|
shuttingDown,
|
|
currentSpeedBps: 0
|
|
});
|
|
const runningEvaluation = deferred();
|
|
const controller = Object.create(AppController.prototype) as any;
|
|
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
|
|
controller.downloadHealthTimer.unref?.();
|
|
controller.downloadHealthMonitor = new DownloadHealthMonitor(filePath);
|
|
controller.downloadHealthEvaluation = runningEvaluation.promise.finally(() => {
|
|
controller.downloadHealthEvaluation = null;
|
|
});
|
|
controller.runtimeStatsTimer = null;
|
|
controller.notificationOutbox = {
|
|
enqueue: vi.fn(async () => undefined),
|
|
drainForShutdown: vi.fn(async () => undefined)
|
|
};
|
|
controller.manager = {
|
|
suspendDownloadHealthMonitoring: vi.fn(() => { shuttingDown = true; }),
|
|
getDownloadHealthSnapshot: vi.fn(() => healthSnapshot(1)),
|
|
flushNotificationsForShutdown: vi.fn(async () => undefined),
|
|
prepareForShutdown: vi.fn()
|
|
};
|
|
controller.megaWebFallback = { dispose: vi.fn() };
|
|
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",
|
|
notifyUrl: "https://discord.example.test/webhook",
|
|
notifyOnDownloadStall: true,
|
|
notifyOnDownloadRecovery: true,
|
|
notifyStallAfterSeconds: 90,
|
|
notifyStallCooldownMinutes: 10
|
|
};
|
|
|
|
const shutdown = controller.shutdown();
|
|
|
|
expect(controller.downloadHealthTimer).toBeNull();
|
|
expect(controller.notificationOutbox.drainForShutdown).not.toHaveBeenCalled();
|
|
runningEvaluation.resolve();
|
|
await shutdown;
|
|
|
|
const closed = loadDownloadHealthState(filePath);
|
|
expect(closed.status).toBe("idle");
|
|
expect(closed.alertedAt).toBe(0);
|
|
expect(closed.restartPending).toBe(false);
|
|
|
|
const recoveredEvents: unknown[] = [];
|
|
const restarted = new DownloadHealthMonitor(filePath);
|
|
await restarted.sample(healthSnapshot(1), 120_000, {
|
|
stallAfterMs: 90_000,
|
|
cooldownMs: 600_000,
|
|
notifyOnStall: true,
|
|
notifyOnRecovery: true
|
|
}, async (event) => { recoveredEvents.push(event); });
|
|
await restarted.sample(healthSnapshot(2), 135_000, {
|
|
stallAfterMs: 90_000,
|
|
cooldownMs: 600_000,
|
|
notifyOnStall: true,
|
|
notifyOnRecovery: true
|
|
}, async (event) => { recoveredEvents.push(event); });
|
|
|
|
expect(recoveredEvents).toEqual([]);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|