fix(scheduler): harden daily start reconciliation

Preserve legacy one-time schedules across internal account state saves and clear them only for explicit renderer daily-rule updates.

Enforce monotone handled and pending calendar receipts across backward local-date changes, invalidate late in-flight start results on shutdown, and align queue eligibility with enabled non-cancelled packages.

Replace the in-memory restart assertion with real settings persistence and add RED/GREEN regression coverage for every reviewed behavior.
This commit is contained in:
Sucukdeluxe
2026-08-22 09:50:37 +02:00
parent 4eb95c92b9
commit 0ca00149eb
5 changed files with 182 additions and 24 deletions
+2 -2
View File
@@ -81,7 +81,7 @@ import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-le
import { NotificationOutbox } from "./notification-outbox";
import { sendNotification } from "./notify";
import { DownloadHealthMonitor } from "./download-health-monitor";
import { prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -521,7 +521,7 @@ export class AppController {
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = prepareDailyStartSettingsPatch(sanitizeSettingsPatch(partial)) as Partial<AppSettings>;
const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings;
let nextSettings = normalizeSettings({
...previousSettings,
+38 -8
View File
@@ -5,7 +5,8 @@ interface DailyStartSnapshot {
session: {
running: boolean;
paused: boolean;
items: Record<string, { status: string }>;
items: Record<string, { status: string; packageId: string }>;
packages: Record<string, { enabled: boolean; cancelled: boolean }>;
};
canStart: boolean;
}
@@ -73,6 +74,12 @@ export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs =
const now = new Date(nowEpochMs);
const today = formatLocalDate(now);
let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today;
const pending = isValidLocalDate(settings.dailyStartPendingLocalDate)
? settings.dailyStartPendingLocalDate
: "";
if (pending && candidate < pending) {
candidate = pending;
}
const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
? settings.dailyStartLastHandledLocalDate
: "";
@@ -105,6 +112,7 @@ export function shouldDeferAutoResumeToDailyStart(
export class DailyStartScheduler {
private reconcileInFlight: Promise<DailyStartOutcome | null> | null = null;
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
private lifecycleGeneration = 0;
public constructor(
private readonly controller: DailyStartController,
@@ -115,7 +123,7 @@ export class DailyStartScheduler {
if (this.reconcileInFlight) {
return this.reconcileInFlight;
}
const operation = this.reconcileOnce();
const operation = this.reconcileOnce(this.lifecycleGeneration);
this.reconcileInFlight = operation;
void operation.finally(() => {
if (this.reconcileInFlight === operation) {
@@ -135,6 +143,7 @@ export class DailyStartScheduler {
}
public end(): void {
this.lifecycleGeneration += 1;
if (this.reconcileTimer !== null) {
clearInterval(this.reconcileTimer);
this.reconcileTimer = null;
@@ -150,7 +159,7 @@ export class DailyStartScheduler {
return outcome;
}
private async reconcileOnce(): Promise<DailyStartOutcome | null> {
private async reconcileOnce(generation: number): Promise<DailyStartOutcome | null> {
const nowEpochMs = this.now();
let settings = this.controller.getSnapshot().settings;
if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) {
@@ -158,17 +167,27 @@ export class DailyStartScheduler {
}
const today = formatLocalDate(new Date(nowEpochMs));
const handledDate = isValidLocalDate(settings.dailyStartLastHandledLocalDate)
? settings.dailyStartLastHandledLocalDate
: "";
const pendingDate = isValidLocalDate(settings.dailyStartPendingLocalDate)
? settings.dailyStartPendingLocalDate
: "";
if ((handledDate && today <= handledDate) || (pendingDate && today < pendingDate)) {
return null;
}
let missed: DailyStartOutcome | null = null;
if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) {
const pendingDate = settings.dailyStartPendingLocalDate;
const missedDate = settings.dailyStartPendingLocalDate;
const lastHandledLocalDate = handledDate > missedDate ? handledDate : missedDate;
this.controller.updateSettings({
dailyStartLastHandledLocalDate: pendingDate,
dailyStartLastHandledLocalDate: lastHandledLocalDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
});
settings = {
...settings,
dailyStartLastHandledLocalDate: pendingDate,
dailyStartLastHandledLocalDate: lastHandledLocalDate,
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "missed"
};
@@ -194,8 +213,13 @@ export class DailyStartScheduler {
if (snapshot.session.running || snapshot.session.paused) {
return this.finish(today, "already_active");
}
const hasQueuedItems = Object.values(snapshot.session.items)
.some((item) => item.status === "queued" || item.status === "reconnect_wait");
const hasQueuedItems = Object.values(snapshot.session.items).some((item) => {
if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false;
}
const pkg = snapshot.session.packages[item.packageId];
return Boolean(pkg && pkg.enabled && !pkg.cancelled);
});
if (!hasQueuedItems) {
return this.finish(today, "empty_queue");
}
@@ -207,9 +231,15 @@ export class DailyStartScheduler {
try {
await this.controller.start();
} catch (error) {
if (generation !== this.lifecycleGeneration) {
return null;
}
this.controller.updateSettings({ dailyStartLastOutcome: "start_failed" });
throw error;
}
if (generation !== this.lifecycleGeneration) {
return null;
}
return this.finish(today, "started");
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
import { DailyStartScheduler, hasDailyStartRulePatch } from "./daily-start-scheduler";
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -464,7 +464,7 @@ function registerIpcHandlers(): void {
});
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
const result = controller.updateSettings(validated as Partial<AppSettings>);
const result = controller.updateSettings(prepareDailyStartSettingsPatch(validated) as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });