Fix: Run-Lebenszyklus — Stop-Summary, Scheduler-Race, ehrliche Run-Ende-Meldung, Boot-Re-Arm
Audit-Befunde RUN-1 bis RUN-4: - RUN-1: Manueller Stop beendete den Durchlauf, ohne dass je eine Run-Summary kam (der Scheduler bricht an der while-Bedingung ab, finishRun wird nie erreicht). stop() schickt jetzt "Durchlauf gestoppt" mit den bis dahin gesammelten Zahlen (nur wenn der Run lief; beim Restart/Shutdown-Pfad unterdrueckt — der Prozess stirbt gleich). - RUN-2: Stop->Start innerhalb des Scheduler-Sleeps (~120-220ms) liess den neuen Run fuehrerlos zurueck: ensureScheduler returnte (scheduleRunning noch true), die alte Schleife exitete auf Generation-Mismatch — danach lief KEIN Scheduler mehr, obwohl running=true: keine Downloads, kein finishRun, kein Webhook, und erneutes Start() heilte nichts (early-return wegen running). Die finally respawnt jetzt den Scheduler, wenn der Run aktiv ist und die Generation weitergezogen wurde. - RUN-3: Geplanter Start ueberlebte keinen App-Neustart (Timer lebte nur im Prozess, Setting blieb stehen) — Auto-Update/Reboot verschluckte den geplanten Run still. armScheduledStart extrahiert, beim Boot re-armt; vergangene Zeit beim Boot wird geloggt+geleert statt blind zu starten (Konflikt mit autoResumeOnStart-Gate). - RUN-4: "Durchlauf beendet" feuerte in der Default-Konfiguration (autoExtractWhenStopped) waehrend das Entpacken noch lief. Titel sagt jetzt "Downloads beendet" + Hinweis "Entpacken laeuft noch — Paket-Meldungen folgen", wenn Post-Processing aussteht.
This commit is contained in:
parent
8acb22d3af
commit
3fb9e85ba2
@ -5640,6 +5640,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const parkForRestart = options?.parkForRestart === true;
|
const parkForRestart = options?.parkForRestart === true;
|
||||||
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
||||||
const keepExtraction = this.settings.autoExtractWhenStopped;
|
const keepExtraction = this.settings.autoExtractWhenStopped;
|
||||||
|
const wasRunning = this.session.running;
|
||||||
this.schedulerGeneration += 1;
|
this.schedulerGeneration += 1;
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
@ -5685,6 +5686,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A manual stop ends the run without ever reaching finishRun (the scheduler
|
||||||
|
// loop exits at its while condition), so the run summary would be lost.
|
||||||
|
// Suppressed for the restart/shutdown path: the process is about to die.
|
||||||
|
if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) {
|
||||||
|
const outcomes = Array.from(this.runOutcomes.values());
|
||||||
|
const success = outcomes.filter((s) => s === "completed").length;
|
||||||
|
const failed = outcomes.filter((s) => s === "failed").length;
|
||||||
|
void sendNotification(this.settings.notifyUrl, {
|
||||||
|
title: "⏹️ Durchlauf gestoppt",
|
||||||
|
message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`,
|
||||||
|
mention: this.settings.notifyMention
|
||||||
|
});
|
||||||
|
}
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
}
|
}
|
||||||
@ -8248,6 +8262,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} finally {
|
} finally {
|
||||||
this.scheduleRunning = false;
|
this.scheduleRunning = false;
|
||||||
logger.info(`Scheduler beendet (gen=${myGeneration})`);
|
logger.info(`Scheduler beendet (gen=${myGeneration})`);
|
||||||
|
// Stop->Start race: a new run can begin while this loop sleeps (start()'s
|
||||||
|
// ensureScheduler early-returns on scheduleRunning, then this loop exits on
|
||||||
|
// the generation mismatch). Without a respawn the run sits leaderless
|
||||||
|
// forever: running=true, but nothing schedules and finishRun never comes.
|
||||||
|
if (this.session.running && this.schedulerGeneration !== myGeneration) {
|
||||||
|
logger.warn(`Scheduler-Respawn: Run aktiv, alte Generation ${myGeneration} beendet (Stop->Start-Race)`);
|
||||||
|
void this.ensureScheduler().catch((error) => logger.error(`Scheduler-Respawn fehlgeschlagen: ${compactErrorText(error)}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12181,9 +12203,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
|
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`;
|
||||||
if (this.settings.notifyOnRunFinished && total > 0) {
|
if (this.settings.notifyOnRunFinished && total > 0) {
|
||||||
|
// With autoExtractWhenStopped (default) the run ends as soon as downloads
|
||||||
|
// are done while extraction may still be running — say so instead of
|
||||||
|
// claiming the whole run is finished.
|
||||||
|
const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending();
|
||||||
|
const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet";
|
||||||
void sendNotification(this.settings.notifyUrl, {
|
void sendNotification(this.settings.notifyUrl, {
|
||||||
title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet",
|
title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`,
|
||||||
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`,
|
message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`,
|
||||||
mention: this.settings.notifyMention
|
mention: this.settings.notifyMention
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,35 @@ function isDevMode(): boolean {
|
|||||||
return process.env.NODE_ENV === "development";
|
return process.env.NODE_ENV === "development";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single owner of the scheduled-start timer. startOnPast: a past time entered
|
||||||
|
// interactively starts right away; at boot a stale past time is cleared instead
|
||||||
|
// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate).
|
||||||
|
function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void {
|
||||||
|
if (scheduledStartTimer !== null) {
|
||||||
|
clearTimeout(scheduledStartTimer);
|
||||||
|
scheduledStartTimer = null;
|
||||||
|
}
|
||||||
|
if (!schedMs || schedMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delay = schedMs - Date.now();
|
||||||
|
if (delay <= 0) {
|
||||||
|
if (opts.startOnPast) {
|
||||||
|
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||||
|
} else {
|
||||||
|
logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`);
|
||||||
|
}
|
||||||
|
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduledStartTimer = setTimeout(() => {
|
||||||
|
scheduledStartTimer = null;
|
||||||
|
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||||
|
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||||
|
}, delay);
|
||||||
|
logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 1920,
|
width: 1920,
|
||||||
@ -328,24 +357,7 @@ function registerIpcHandlers(): void {
|
|||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
if (scheduledStartTimer !== null) {
|
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
||||||
clearTimeout(scheduledStartTimer);
|
|
||||||
scheduledStartTimer = null;
|
|
||||||
}
|
|
||||||
const schedMs = result.scheduledStartEpochMs || 0;
|
|
||||||
if (schedMs > 0) {
|
|
||||||
const delay = schedMs - Date.now();
|
|
||||||
if (delay <= 0) {
|
|
||||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
|
||||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
|
||||||
} else {
|
|
||||||
scheduledStartTimer = setTimeout(() => {
|
|
||||||
scheduledStartTimer = null;
|
|
||||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
|
||||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
||||||
@ -807,6 +819,10 @@ app.whenReady().then(() => {
|
|||||||
bindMainWindowLifecycle(mainWindow);
|
bindMainWindowLifecycle(mainWindow);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
|
// A scheduled start persists in the settings but its timer lived only in this
|
||||||
|
// process — without re-arming it here, any restart (auto-update, reboot,
|
||||||
|
// crash) silently swallowed the planned run.
|
||||||
|
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user