fix(scheduler): harden scheduled actions and speed limits

- Persist scheduler dispatch markers and retry unacknowledged events across renderer and process restarts.
- Fence queue admission, Torrent moves, and permit activation during system actions with an explicit force path.
- Preserve Aria2 zero-limit overrides and normalize global limits across startup, queued, retry, and live paths.
- Guard scheduler lifecycle races, completion post-actions, settings bindings, and regression coverage.
This commit is contained in:
NimBold
2026-08-12 18:57:31 +03:30
parent 885e3d0100
commit e9ad226b93
22 changed files with 528 additions and 71 deletions
+23 -3
View File
@@ -12,6 +12,7 @@ import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../utils/dateTime';
import { beginSchedulerControl, isSchedulerControlCurrent } from '../utils/schedulerControl';
const days = [
{ value: 0, key: 'su' },
@@ -30,7 +31,8 @@ const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [
{ value: 'shutdown', icon: Power },
];
const minuteOfDay = (value: string) => {
const minuteOfDay = (value: string): number | null => {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return null;
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
};
@@ -38,7 +40,10 @@ const minuteOfDay = (value: string) => {
function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' {
if (!settings.enabled) return 'disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const startMinute = minuteOfDay(settings.startTime);
if (startMinute === null) return 'none';
const hour = Math.floor(startMinute / 60);
const minute = startMinute % 60;
const now = new Date();
for (let offset = 0; offset < 8; offset += 1) {
@@ -136,7 +141,13 @@ export default function SchedulerView() {
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
const startMinute = minuteOfDay(draft.startTime);
const stopMinute = minuteOfDay(draft.stopTime);
if (draft.enabled && (startMinute === null || (draft.stopTimeEnabled && stopMinute === null))) {
addToast({ message: t($ => $.scheduler.validationTime), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && stopMinute === startMinute) {
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
return;
}
@@ -151,11 +162,18 @@ export default function SchedulerView() {
};
const runNow = async () => {
const generation = beginSchedulerControl();
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const acceptedIds = results.flat();
if (!isSchedulerControlCurrent(generation)) {
await Promise.allSettled(
acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id))
);
return;
}
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
@@ -180,9 +198,11 @@ export default function SchedulerView() {
};
const pauseNow = async () => {
const generation = beginSchedulerControl();
const counts = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
if (!isSchedulerControlCurrent(generation)) return;
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);