fix: recover app lifecycle after provider downloads
This commit is contained in:
+21
-5
@@ -96,6 +96,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- Settings-Speichern, Theme-Autorität, vollständige DE/EN-Texte, Backup-Transaktionen, Notification-Outbox und Windows-Dateirennen wurden gehärtet.
|
||||
- Die letzten drei Commits des Release-Branches sind `bdcf9e3` (Collector-Persistenz und Download-Steuerung), `2ea494c` (v2.0.74 Reliability Release) und `c1e1095` (stabiler 100k-History-Performancevertrag).
|
||||
|
||||
## Aktuelle unveröffentlichte Änderung
|
||||
|
||||
- Ein Start-/Beenden-Fehler nach Real-Debrid-Web-Downloads wurde behoben. Der persistente unsichtbare Web-Generator blieb nach dem Schließen des Hauptfensters geöffnet, verhinderte dadurch `window-all-closed` und hielt den Single-Instance-Lock. Weitere Starts wurden als Zweitinstanz sofort beendet, während der alte `second-instance`-Handler ohne Hauptfenster nichts tat.
|
||||
- Das Hauptfenster ist unter Windows nun ausdrücklich Besitzer des App-Lebenszyklus: Nach einem natürlichen, vom Renderer bestätigten Schließen wird der kontrollierte Shutdown auch bei verbleibenden Providerfenstern gestartet. Der normale `beforeunload`-Pfad bleibt erhalten, damit noch nicht übertragene Linksammler-Änderungen synchron gesichert werden können.
|
||||
- `second-instance` und `activate` stellen ein vorhandenes Hauptfenster wieder her oder erzeugen ein fehlendes neu. Ein Start während eines bereits laufenden Shutdowns plant genau einen Relaunch nach dem Prozessende ein.
|
||||
- Der Shutdown besitzt einen äußeren 10-Sekunden-Wächter. Nach erfolgreichem Controller-Shutdown wird weiterhin normal mit `app.quit()` beendet; blockieren Renderer- oder Providerfenster diesen letzten Schritt, erzwingt ein separater 2-Sekunden-Wächter den Exit. `shutdownDaemon()` läuft im Graceful-Pfad erst nach `controller.shutdown()`.
|
||||
- Die Änderung ist nur auf dem Arbeitsbranch implementiert und getestet. Sie wurde weder veröffentlicht noch auf dem Server installiert; dafür ist eine neue ausdrückliche Release-/Deployment-Freigabe erforderlich.
|
||||
|
||||
## Start-, Build- und Testbefehle
|
||||
|
||||
```powershell
|
||||
@@ -139,6 +147,11 @@ npm exec -- tsc --noEmit
|
||||
- Client-Suite ohne `tests/public-release-metadata.test.ts`: 138 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.600 Tests erfolgreich und 4 übersprungen.
|
||||
- Vollständiger erster Client-Lauf: 2.621 Tests erfolgreich, 4 JVM-Extractor-Tests übersprungen; zwei Tests in `public-release-metadata.test.ts` scheiterten vor ihrer Assertion ausschließlich an fehlender Windows-Symlink-Berechtigung.
|
||||
- GitHub: keine offenen Issues, keine Pull Requests und keine Actions-Läufe vorhanden.
|
||||
- Lifecycle-Regressionen nach dem Start-/Beenden-Fix: 20 von 20 erfolgreich. Abgedeckt sind ein nie endender Shutdown, Fenster-Quit-Veto, Linksammler-`beforeunload`, Tray-Verhalten, fehlendes Hauptfenster, Zweitinstanz-Wiederherstellung und genau ein Relaunch während des Shutdowns.
|
||||
- Reale Windows-Electron-Prüfung mit isoliertem temporärem Profil: App gestartet, echtes AllDebrid-Providerfenster geöffnet, nur das Hauptfenster per `WM_CLOSE` geschlossen. Der gesamte Electron-Prozessbaum war trotz verbleibendem Providerfenster nach 271 ms beendet. Ein unmittelbarer Neustart mit demselben Profil erzeugte wieder ein Hauptfenster und ließ sich erneut vollständig beenden. Das temporäre Profil wurde danach entfernt.
|
||||
- Vollständiger Client-Lauf ohne den privilegierten Symlink-Metadatentest: 138 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.609 Tests erfolgreich und 4 übersprungen. Ein unter paralleler Build-Last einmal auffälliger, fachfremder Ordner-Aufräumtest war anschließend dreimal isoliert und im vollständigen Wiederholungslauf erfolgreich.
|
||||
- `public-release-metadata.test.ts` separat: 22 von 24 erfolgreich. Die zwei übrigen Fälle scheitern weiterhin ausschließlich beim Anlegen ihrer Symlink-Fixtures mit Windows-`EPERM`, bevor die Produktassertion ausgeführt wird.
|
||||
- Nach dem Lifecycle-Fix erneut erfolgreich: TypeScript, vollständiger Main-/Renderer-Build, Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung zum rund 574 KiB großen Renderer-Chunk bleibt bestehen.
|
||||
|
||||
## Bekannte Probleme und Risiken
|
||||
|
||||
@@ -150,11 +163,14 @@ npm exec -- tsc --noEmit
|
||||
- Der Arbeitsordner enthält `&`; ohne PowerShell-7-Skriptshell können npm-`cmd`-Shims fehlschlagen.
|
||||
- Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository.
|
||||
- Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle.
|
||||
- Die Shutdown-Wächter können einen vollständig synchron blockierten Main-Thread nicht präemptieren. Im äußersten Fall beträgt die kombinierte Grenze knapp 12 Sekunden: bis zu 10 Sekunden Controller-Shutdown plus 2 Sekunden Quit-Bestätigung.
|
||||
- Beim erzwungenen Exit können letzte asynchrone Logzeilen oder Benachrichtigungen fehlen; die wesentlichen Queue-, Settings-, Statistik- und Collector-Daten werden zuvor synchron gesichert. Laufende externe Extraktions-/Remux-Prozesse bleiben ein separater späterer Härtungspunkt.
|
||||
- Der Start-/Beenden-Fix ist noch nicht veröffentlicht oder auf dem Server installiert. Bis zu einer ausdrücklich freigegebenen Auslieferung läuft dort weiterhin die bisherige Version.
|
||||
|
||||
## Nächste sinnvolle Schritte
|
||||
|
||||
1. Für neue Änderungen auf `codex/v2.0.74-project-memory` oder einem davon abgeleiteten Feature-Branch bleiben.
|
||||
2. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
|
||||
3. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
|
||||
4. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten.
|
||||
5. Vor jedem späteren Release Stand, Ziel, Tests und Rollback-Weg nennen und ein ausdrückliches Release-Go abwarten.
|
||||
1. Für eine Auslieferung des Start-/Beenden-Fixes zuerst Stand, Ziel, Tests und Rollback-Weg nennen und Saschas ausdrückliches Release-/Deployment-Go abwarten.
|
||||
2. Nach einer freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
|
||||
3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
|
||||
4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
|
||||
5. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten.
|
||||
|
||||
+218
-29
@@ -102,8 +102,12 @@ let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let dailyStartScheduler: DailyStartScheduler | null = null;
|
||||
let lastClipboardText = "";
|
||||
let controller: AppController;
|
||||
let applicationStarted = false;
|
||||
let appShutdownStarted = false;
|
||||
let pendingBackupImport: Buffer | null = null;
|
||||
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
|
||||
export const APP_SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
export const APP_QUIT_CONFIRM_TIMEOUT_MS = 2_000;
|
||||
|
||||
function reconcileDailyStart(source: string): void {
|
||||
void dailyStartScheduler?.reconcile().catch((error) => {
|
||||
@@ -135,12 +139,37 @@ export interface BeforeQuitHandlerOptions {
|
||||
cleanup: () => void;
|
||||
shutdown: () => Promise<void>;
|
||||
continueQuit: () => void;
|
||||
forceQuit: () => void;
|
||||
onError: (error: unknown) => void;
|
||||
onTimeout: () => void;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (event: { preventDefault: () => void }) => void {
|
||||
let shutdownStarted = false;
|
||||
let quitAllowed = false;
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (forced: boolean): void => {
|
||||
if (quitAllowed) {
|
||||
return;
|
||||
}
|
||||
quitAllowed = true;
|
||||
if (shutdownTimer !== null) {
|
||||
clearTimeout(shutdownTimer);
|
||||
shutdownTimer = null;
|
||||
}
|
||||
if (forced) {
|
||||
try {
|
||||
options.onTimeout();
|
||||
} finally {
|
||||
options.forceQuit();
|
||||
}
|
||||
} else {
|
||||
options.continueQuit();
|
||||
}
|
||||
};
|
||||
|
||||
return (event) => {
|
||||
if (quitAllowed) {
|
||||
return;
|
||||
@@ -150,6 +179,7 @@ export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (eve
|
||||
return;
|
||||
}
|
||||
shutdownStarted = true;
|
||||
shutdownTimer = setTimeout(() => finish(true), options.timeoutMs);
|
||||
let shutdown: Promise<void>;
|
||||
try {
|
||||
options.cleanup();
|
||||
@@ -160,12 +190,129 @@ export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (eve
|
||||
void shutdown.catch((error) => {
|
||||
options.onError(error);
|
||||
}).finally(() => {
|
||||
quitAllowed = true;
|
||||
options.continueQuit();
|
||||
finish(false);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainWindowActivationTarget {
|
||||
isDestroyed: () => boolean;
|
||||
isMinimized: () => boolean;
|
||||
restore: () => void;
|
||||
show: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export function restoreOrCreateMainWindow<T extends MainWindowActivationTarget>(
|
||||
currentWindow: T | null,
|
||||
create: () => T,
|
||||
bind: (window: T) => void
|
||||
): { window: T; created: boolean } {
|
||||
let window = currentWindow;
|
||||
let created = false;
|
||||
if (!window || window.isDestroyed()) {
|
||||
window = create();
|
||||
bind(window);
|
||||
created = true;
|
||||
}
|
||||
if (window.isMinimized()) {
|
||||
window.restore();
|
||||
}
|
||||
window.show();
|
||||
window.focus();
|
||||
return { window, created };
|
||||
}
|
||||
|
||||
export interface MainWindowClosedHandlerOptions {
|
||||
isCurrentWindow: () => boolean;
|
||||
clearCurrentWindow: () => void;
|
||||
isShutdownStarted: () => boolean;
|
||||
quit: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export function createMainWindowClosedHandler(options: MainWindowClosedHandlerOptions): () => void {
|
||||
return () => {
|
||||
if (!options.isCurrentWindow()) {
|
||||
return;
|
||||
}
|
||||
options.clearCurrentWindow();
|
||||
if ((options.platform ?? process.platform) !== "darwin" && !options.isShutdownStarted()) {
|
||||
options.quit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainWindowCloseHandlerOptions {
|
||||
isShutdownStarted: () => boolean;
|
||||
shouldMinimizeToTray: () => boolean;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export function createMainWindowCloseHandler(options: MainWindowCloseHandlerOptions): (event: { preventDefault: () => void }) => void {
|
||||
return (event) => {
|
||||
if (options.isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
if (options.shouldMinimizeToTray()) {
|
||||
event.preventDefault();
|
||||
options.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConfirmedQuitHandlerOptions {
|
||||
requestQuit: () => void;
|
||||
forceQuit: () => void;
|
||||
onQuit: (listener: () => void) => void;
|
||||
onTimeout: () => void;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export function createConfirmedQuitHandler(options: ConfirmedQuitHandlerOptions): () => void {
|
||||
let requested = false;
|
||||
return () => {
|
||||
if (requested) {
|
||||
return;
|
||||
}
|
||||
requested = true;
|
||||
let completed = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
try {
|
||||
options.onTimeout();
|
||||
} finally {
|
||||
options.forceQuit();
|
||||
}
|
||||
}, options.timeoutMs);
|
||||
options.onQuit(() => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
options.requestQuit();
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelaunchScheduler(relaunch: () => void): () => boolean {
|
||||
let scheduled = false;
|
||||
return () => {
|
||||
if (scheduled) {
|
||||
return false;
|
||||
}
|
||||
scheduled = true;
|
||||
relaunch();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const scheduleApplicationRelaunch = createRelaunchScheduler(() => app.relaunch());
|
||||
|
||||
function isDevMode(): boolean {
|
||||
return process.env.NODE_ENV === "development";
|
||||
}
|
||||
@@ -331,19 +478,27 @@ function allowRendererReload(): boolean {
|
||||
}
|
||||
|
||||
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
||||
window.on("close", (event) => {
|
||||
const settings = controller.getSettings();
|
||||
if (settings.minimizeToTray && tray) {
|
||||
event.preventDefault();
|
||||
window.hide();
|
||||
const requestQuit = (): void => {
|
||||
if (appShutdownStarted) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
appShutdownStarted = true;
|
||||
logger.info("Hauptfenster geschlossen – Anwendung wird beendet");
|
||||
app.quit();
|
||||
};
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
window.on("close", createMainWindowCloseHandler({
|
||||
isShutdownStarted: () => appShutdownStarted,
|
||||
shouldMinimizeToTray: () => controller.getSettings().minimizeToTray && tray !== null,
|
||||
hide: () => window.hide()
|
||||
}));
|
||||
|
||||
window.on("closed", createMainWindowClosedHandler({
|
||||
isCurrentWindow: () => mainWindow === window,
|
||||
clearCurrentWindow: () => { mainWindow = null; },
|
||||
isShutdownStarted: () => appShutdownStarted,
|
||||
quit: requestQuit
|
||||
}));
|
||||
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
|
||||
@@ -806,7 +961,7 @@ function registerIpcHandlers(): void {
|
||||
handleTrusted(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
||||
|
||||
handleTrusted(IPC_CHANNELS.RESTART, () => {
|
||||
app.relaunch();
|
||||
scheduleApplicationRelaunch();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -1051,7 +1206,7 @@ function registerIpcHandlers(): void {
|
||||
// the running queue.
|
||||
if (importResult.restored && importResult.relaunch) {
|
||||
setTimeout(() => {
|
||||
app.relaunch();
|
||||
scheduleApplicationRelaunch();
|
||||
app.quit();
|
||||
}, 1500);
|
||||
}
|
||||
@@ -1116,13 +1271,20 @@ app.on("child-process-gone", (_event, details) => {
|
||||
});
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
if (!applicationStarted) {
|
||||
return;
|
||||
}
|
||||
if (appShutdownStarted) {
|
||||
if (scheduleApplicationRelaunch()) {
|
||||
logger.info("Zweite Instanz während des Beendens erkannt – Neustart wird eingeplant");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = restoreOrCreateMainWindow(mainWindow, createWindow, bindMainWindowLifecycle);
|
||||
mainWindow = result.window;
|
||||
logger.info(result.created
|
||||
? "Zweite Instanz erkannt – Hauptfenster neu erstellt"
|
||||
: "Zweite Instanz erkannt – Hauptfenster wiederhergestellt");
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -1133,6 +1295,7 @@ app.whenReady().then(() => {
|
||||
registerIpcHandlers();
|
||||
mainWindow = createWindow();
|
||||
bindMainWindowLifecycle(mainWindow);
|
||||
applicationStarted = true;
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
// A scheduled start persists in the settings but its timer lived only in this
|
||||
@@ -1146,10 +1309,11 @@ app.whenReady().then(() => {
|
||||
powerMonitor.on("resume", handlePowerResume);
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
bindMainWindowLifecycle(mainWindow);
|
||||
if (!applicationStarted || appShutdownStarted) {
|
||||
return;
|
||||
}
|
||||
const result = restoreOrCreateMainWindow(mainWindow, createWindow, bindMainWindowLifecycle);
|
||||
mainWindow = result.window;
|
||||
});
|
||||
}).catch((error) => {
|
||||
logger.error(`App-Start fehlgeschlagen: ${String(error?.stack || error)}`);
|
||||
@@ -1164,22 +1328,47 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const confirmApplicationQuit = createConfirmedQuitHandler({
|
||||
requestQuit: () => app.quit(),
|
||||
forceQuit: () => {
|
||||
shutdownDaemon();
|
||||
app.exit(0);
|
||||
},
|
||||
onQuit: (listener) => app.once("quit", listener),
|
||||
onTimeout: () => {
|
||||
logger.error(`Fenster blockieren das Beenden seit ${APP_QUIT_CONFIRM_TIMEOUT_MS} ms – Prozess wird beendet`);
|
||||
},
|
||||
timeoutMs: APP_QUIT_CONFIRM_TIMEOUT_MS
|
||||
});
|
||||
|
||||
app.on("before-quit", createBeforeQuitHandler({
|
||||
cleanup: () => {
|
||||
appShutdownStarted = true;
|
||||
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
|
||||
cleanupSchedulerLifecycle(dailyStartScheduler, scheduledStartTimer);
|
||||
scheduledStartTimer = null;
|
||||
stopClipboardWatcher();
|
||||
destroyTray();
|
||||
shutdownDaemon();
|
||||
},
|
||||
shutdown: async () => {
|
||||
if (controller) {
|
||||
await controller.shutdown();
|
||||
try {
|
||||
if (controller) {
|
||||
await controller.shutdown();
|
||||
}
|
||||
} finally {
|
||||
shutdownDaemon();
|
||||
}
|
||||
},
|
||||
continueQuit: () => app.quit(),
|
||||
continueQuit: confirmApplicationQuit,
|
||||
forceQuit: () => {
|
||||
shutdownDaemon();
|
||||
app.exit(0);
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`Fehler beim Shutdown: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
onTimeout: () => {
|
||||
logger.error(`Shutdown nach ${APP_SHUTDOWN_TIMEOUT_MS} ms erzwungen`);
|
||||
},
|
||||
timeoutMs: APP_SHUTDOWN_TIMEOUT_MS
|
||||
}));
|
||||
|
||||
@@ -302,7 +302,10 @@ describe("main shutdown lifecycle", () => {
|
||||
cleanup,
|
||||
shutdown: optionsShutdown,
|
||||
continueQuit,
|
||||
onError
|
||||
forceQuit: vi.fn(),
|
||||
onError,
|
||||
onTimeout: vi.fn(),
|
||||
timeoutMs: main.APP_SHUTDOWN_TIMEOUT_MS
|
||||
});
|
||||
const first = { preventDefault: vi.fn() };
|
||||
const repeated = { preventDefault: vi.fn() };
|
||||
@@ -332,15 +335,20 @@ describe("main shutdown lifecycle", () => {
|
||||
const scheduler = { end: vi.fn() };
|
||||
const timer = setTimeout(() => {}, 60_000);
|
||||
const shutdown = vi.fn(async () => undefined);
|
||||
const continueQuit = vi.fn();
|
||||
const handler = main.createBeforeQuitHandler({
|
||||
cleanup: () => main.cleanupSchedulerLifecycle(scheduler, timer),
|
||||
shutdown,
|
||||
continueQuit: vi.fn(),
|
||||
onError: vi.fn()
|
||||
continueQuit,
|
||||
forceQuit: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onTimeout: vi.fn(),
|
||||
timeoutMs: main.APP_SHUTDOWN_TIMEOUT_MS
|
||||
});
|
||||
|
||||
handler({ preventDefault: vi.fn() });
|
||||
await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(continueQuit).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(scheduler.end).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
@@ -350,6 +358,181 @@ describe("main shutdown lifecycle", () => {
|
||||
expect(electron.powerMonitor.removeListener.mock.invocationCallOrder[1]).toBeLessThan(shutdown.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("forces process exit when shutdown never settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
const main = await import("../src/main/main");
|
||||
const continueQuit = vi.fn();
|
||||
const forceQuit = vi.fn();
|
||||
const onTimeout = vi.fn();
|
||||
const handler = main.createBeforeQuitHandler({
|
||||
cleanup: vi.fn(),
|
||||
shutdown: () => new Promise<void>(() => {}),
|
||||
continueQuit,
|
||||
forceQuit,
|
||||
onError: vi.fn(),
|
||||
onTimeout,
|
||||
timeoutMs: main.APP_SHUTDOWN_TIMEOUT_MS
|
||||
});
|
||||
const first = { preventDefault: vi.fn() };
|
||||
const resumed = { preventDefault: vi.fn() };
|
||||
|
||||
handler(first);
|
||||
await vi.advanceTimersByTimeAsync(main.APP_SHUTDOWN_TIMEOUT_MS - 1);
|
||||
expect(forceQuit).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(forceQuit).toHaveBeenCalledTimes(1);
|
||||
expect(continueQuit).not.toHaveBeenCalled();
|
||||
handler(resumed);
|
||||
expect(resumed.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests a controlled quit after the main window closes while a hidden provider window remains", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const hiddenProviderWindow = { visible: false };
|
||||
const remainingWindows = [hiddenProviderWindow];
|
||||
let currentWindow: object | null = {};
|
||||
const quit = vi.fn();
|
||||
const handler = main.createMainWindowClosedHandler({
|
||||
isCurrentWindow: () => currentWindow !== null,
|
||||
clearCurrentWindow: () => { currentWindow = null; },
|
||||
isShutdownStarted: () => false,
|
||||
quit,
|
||||
platform: "win32"
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
expect(remainingWindows).toEqual([hiddenProviderWindow]);
|
||||
expect(currentWindow).toBeNull();
|
||||
expect(quit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a normal close to reach the renderer beforeunload save", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const hide = vi.fn();
|
||||
const event = { preventDefault: vi.fn() };
|
||||
const handler = main.createMainWindowCloseHandler({
|
||||
isShutdownStarted: () => false,
|
||||
shouldMinimizeToTray: () => false,
|
||||
hide
|
||||
});
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(hide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the normal minimize-to-tray close behavior", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const hide = vi.fn();
|
||||
const event = { preventDefault: vi.fn() };
|
||||
const handler = main.createMainWindowCloseHandler({
|
||||
isShutdownStarted: () => false,
|
||||
shouldMinimizeToTray: () => true,
|
||||
hide
|
||||
});
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(hide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let tray handling intercept a shutdown-owned window close", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const hide = vi.fn();
|
||||
const event = { preventDefault: vi.fn() };
|
||||
const handler = main.createMainWindowCloseHandler({
|
||||
isShutdownStarted: () => true,
|
||||
shouldMinimizeToTray: () => true,
|
||||
hide
|
||||
});
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(hide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces exit when BrowserWindows veto the confirmed quit", async () => {
|
||||
vi.useFakeTimers();
|
||||
const main = await import("../src/main/main");
|
||||
const requestQuit = vi.fn();
|
||||
const forceQuit = vi.fn();
|
||||
const onTimeout = vi.fn();
|
||||
const handler = main.createConfirmedQuitHandler({
|
||||
requestQuit,
|
||||
forceQuit,
|
||||
onQuit: vi.fn(),
|
||||
onTimeout,
|
||||
timeoutMs: main.APP_QUIT_CONFIRM_TIMEOUT_MS
|
||||
});
|
||||
|
||||
handler();
|
||||
await vi.advanceTimersByTimeAsync(main.APP_QUIT_CONFIRM_TIMEOUT_MS - 1);
|
||||
expect(forceQuit).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(requestQuit).toHaveBeenCalledTimes(1);
|
||||
expect(onTimeout).toHaveBeenCalledTimes(1);
|
||||
expect(forceQuit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels the forced exit after Electron confirms quit", async () => {
|
||||
vi.useFakeTimers();
|
||||
const main = await import("../src/main/main");
|
||||
let confirmQuit = () => {};
|
||||
const forceQuit = vi.fn();
|
||||
const handler = main.createConfirmedQuitHandler({
|
||||
requestQuit: vi.fn(),
|
||||
forceQuit,
|
||||
onQuit: (listener) => { confirmQuit = listener; },
|
||||
onTimeout: vi.fn(),
|
||||
timeoutMs: main.APP_QUIT_CONFIRM_TIMEOUT_MS
|
||||
});
|
||||
|
||||
handler();
|
||||
confirmQuit();
|
||||
await vi.advanceTimersByTimeAsync(main.APP_QUIT_CONFIRM_TIMEOUT_MS);
|
||||
|
||||
expect(forceQuit).not.toHaveBeenCalled();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("recreates a missing main window when a second instance starts", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const createdWindow = {
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn()
|
||||
};
|
||||
const create = vi.fn(() => createdWindow);
|
||||
const bind = vi.fn();
|
||||
|
||||
const result = main.restoreOrCreateMainWindow(null, create, bind);
|
||||
|
||||
expect(result).toEqual({ window: createdWindow, created: true });
|
||||
expect(bind).toHaveBeenCalledWith(createdWindow);
|
||||
expect(createdWindow.show).toHaveBeenCalledTimes(1);
|
||||
expect(createdWindow.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("schedules exactly one relaunch when starts arrive during shutdown", async () => {
|
||||
const main = await import("../src/main/main");
|
||||
const relaunch = vi.fn();
|
||||
const schedule = main.createRelaunchScheduler(relaunch);
|
||||
|
||||
expect(schedule()).toBe(true);
|
||||
expect(schedule()).toBe(false);
|
||||
expect(schedule()).toBe(false);
|
||||
expect(relaunch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user