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
+78 -35
View File
@@ -42,6 +42,7 @@ import { formatDownloadBytes } from './utils/downloadProgress';
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
import { createMainWindowSizePersistence } from './utils/mainWindowState';
import type { MainWindowSize } from './bindings/MainWindowSize';
import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl';
const loadSettingsView = () => import('./components/SettingsView');
const loadSchedulerView = () => import('./components/SchedulerView');
@@ -106,7 +107,6 @@ const PageLoadingFallback = () => {
};
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
let powerPreferencesSync: Promise<void> = Promise.resolve();
const waitForSettingsHydration = (): Promise<void> => {
@@ -264,7 +264,6 @@ function App() {
const preventsDisplaySleepWhileDownloading = useSettingsStore(
state => state.preventsDisplaySleepWhileDownloading
);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
@@ -314,6 +313,55 @@ function App() {
const actionLabel = t($ => $.scheduler.postActions[action]);
let timerId: number | null = null;
let toastId: string | null = null;
const showForceActionToast = () => {
let forceToastId: string | null = null;
const proceed = () => {
if (forceToastId !== null) {
removeToast(forceToastId);
forceToastId = null;
}
invoke('perform_system_action', { action, force: true }).catch(error => {
console.error('Forced scheduled post action failed:', error);
addToast({
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
};
forceToastId = addToast({
variant: 'warning',
isActionable: true,
duration: 0,
message: (
<div className="flex items-center gap-3">
<span>{t($ => $.app.systemActionCancelled)}</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={proceed}
>
{t($ => $.app.systemActionProceedAnyway)}
</button>
</div>
)
});
};
const perform = (force: boolean) => {
invoke('perform_system_action', { action, force }).catch(error => {
const detail = String(error);
if (!force && detail.includes('active or queued')) {
showForceActionToast();
return;
}
console.error('Scheduled post action failed:', error);
addToast({
message: t($ => $.app.systemActionFailed, { detail }),
variant: 'error',
isActionable: true
});
});
};
const cancel = () => {
clearPendingPostActionTimer();
timerId = null;
@@ -355,24 +403,13 @@ function App() {
isActiveDownloadStatus(download.status)
);
if (activeTransfers) {
addToast({
message: t($ => $.app.systemActionCancelled),
variant: 'warning',
isActionable: true
});
showForceActionToast();
return;
}
invoke('perform_system_action', { action }).catch(error => {
console.error('Scheduled post action failed:', error);
addToast({
message: t($ => $.app.systemActionFailed, { detail: String(error) }),
variant: 'error',
isActionable: true
});
});
perform(false);
}, 10_000);
pendingPostActionTimer.current = timerId;
}, [addToast, clearPendingPostActionTimer, removeToast]);
}, [addToast, clearPendingPostActionTimer, removeToast, t]);
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -402,12 +439,6 @@ function App() {
return clearPendingPostActionTimer;
}, [clearPendingPostActionTimer]);
useEffect(() => {
if (activeTransferCount > 0) {
clearPendingPostActionTimer();
}
}, [activeTransferCount, clearPendingPostActionTimer]);
useEffect(() => {
initMediaDomains();
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
@@ -841,6 +872,11 @@ function App() {
useEffect(() => {
if (!coreReady) return;
// Scope duplicate suppression to this listener instance. A module-level
// set can retain a key across a webview/listener restart while the old
// async handler is still unwinding, causing the replacement listener to
// drop the only retry for that scheduled action.
const processingScheduleKeys = new Set<string>();
const unlisten = listen('schedule-trigger', async (event) => {
const state = useSettingsStore.getState();
const payload = event.payload;
@@ -848,6 +884,7 @@ function App() {
processingScheduleKeys.add(payload.key);
try {
if (payload.action === 'start') {
const generation = beginSchedulerControl();
clearPendingPostActionTimer();
const scheduledQueueIds = getScheduledQueueIds();
if (scheduledQueueIds.length === 0) {
@@ -866,6 +903,13 @@ function App() {
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const acceptedIds = startedResults.flat();
if (!isSchedulerControlCurrent(generation)) {
await Promise.allSettled(
acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id))
);
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
return;
}
const scheduledQueueSet = new Set(scheduledQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
@@ -879,14 +923,18 @@ function App() {
state.setSchedulerRunning(activeIds.length > 0);
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
} else if (payload.action === 'stop') {
const generation = beginSchedulerControl();
// A stop event can race with the completion effect's post-action
// countdown after it has already cleared the tracked IDs. Always
// cancel that pending action before applying the stop transition.
clearPendingPostActionTimer();
const trackedIds = state.schedulerActiveDownloadIds;
if (trackedIds.length > 0) {
clearPendingPostActionTimer();
const pauseResults = await Promise.allSettled(
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
);
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
if (failedPauses > 0) {
if (failedPauses > 0 && isSchedulerControlCurrent(generation)) {
addToast({
message: failedPauses === 1
? t($ => $.app.schedulerPauseOneFailed)
@@ -896,8 +944,10 @@ function App() {
});
}
}
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
if (isSchedulerControlCurrent(generation)) {
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
}
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
}
} finally {
@@ -906,6 +956,7 @@ function App() {
});
return () => {
beginSchedulerControl();
unlisten.then(f => f()).catch(console.error);
};
}, [addToast, clearPendingPostActionTimer, coreReady]);
@@ -929,15 +980,7 @@ function App() {
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
addToast({
message: t($ => $.app.scheduledActionSkippedActive),
variant: 'warning',
isActionable: true
});
} else {
schedulePostQueueAction(settings.scheduler.postQueueAction);
}
schedulePostQueueAction(settings.scheduler.postQueueAction);
}
}, [
addToast,
+1 -1
View File
@@ -12,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+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([]);
+2
View File
@@ -196,6 +196,7 @@ const common = {
linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.',
validationDay: 'Select at least one day for the scheduler',
validationQueue: 'Select at least one queue for the scheduler',
validationTime: 'Enter valid times in HH:MM format',
validationStopTime: 'Stop time must be later than start time',
saved: 'Scheduler settings saved',
trackingOne: 'Tracking 1 scheduled download',
@@ -513,6 +514,7 @@ const common = {
settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.',
systemActionCountdown: '{{action}} in 10 seconds.',
systemActionCancelled: 'System action cancelled because another download is active or queued.',
systemActionProceedAnyway: 'Proceed anyway',
systemActionFailed: 'Scheduled system action failed: {{detail}}',
downloadCompleteTitle: 'Download Complete',
downloadCompleteBody: '{{fileName}} has finished downloading.',
+2
View File
@@ -196,6 +196,7 @@ const fa = {
linuxActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.',
validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید',
validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید',
validationTime: 'زمان‌ها را با قالب معتبر HH:MM وارد کنید',
validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد',
saved: 'تنظیمات زمان‌بند ذخیره شد',
trackingOne: 'در حال پیگیری ۱ دانلود زمان‌بندی‌شده',
@@ -513,6 +514,7 @@ const fa = {
settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسی‌های ذخیره‌سازی را بررسی کرده و دوباره امتحان کنید.',
systemActionCountdown: '{{action}} در ۱۰ ثانیه.',
systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.',
systemActionProceedAnyway: 'ادامه دادن به هر حال',
systemActionFailed: 'اقدام سیستم زمان‌بندی‌شده ناموفق بود: {{detail}}',
downloadCompleteTitle: 'دانلود تکمیل‌شده',
downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.',
+2
View File
@@ -196,6 +196,7 @@ const he = {
linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.',
validationDay: 'יש לבחור לפחות יום אחד למתזמן',
validationQueue: 'יש לבחור לפחות תור אחד למתזמן',
validationTime: 'יש להזין שעות תקינות בתבנית HH:MM',
validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה',
saved: 'הגדרות המתזמן נשמרו',
trackingOne: 'עוקב אחר הורדה מתוזמנת אחת',
@@ -513,6 +514,7 @@ const he = {
settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.',
systemActionCountdown: '{{action}} בעוד 10 שניות.',
systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.',
systemActionProceedAnyway: 'להמשיך בכל זאת',
systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}',
downloadCompleteTitle: 'ההורדה הושלמה',
downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.',
+2
View File
@@ -196,6 +196,7 @@ const ru = {
linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.',
validationDay: 'Выберите хотя бы один день для планировщика',
validationQueue: 'Выберите хотя бы одну очередь для планировщика',
validationTime: 'Введите корректное время в формате HH:MM',
validationStopTime: 'Время окончания должно быть позже времени начала',
saved: 'Настройки планировщика сохранены',
trackingOne: 'Отслеживается 1 запланированная загрузка',
@@ -513,6 +514,7 @@ const ru = {
settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.',
systemActionProceedAnyway: 'Всё равно продолжить',
systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}',
downloadCompleteTitle: 'Загрузка завершена',
downloadCompleteBody: 'Загрузка {{fileName}} завершена.',
+2
View File
@@ -196,6 +196,7 @@ const uk = {
linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.',
validationDay: 'Виберіть принаймні один день для планувальника',
validationQueue: 'Виберіть принаймні одну чергу для планувальника',
validationTime: 'Введіть коректний час у форматі HH:MM',
validationStopTime: 'Час зупинки має бути пізнішим за час початку',
saved: 'Налаштування планувальника збережено',
trackingOne: 'Відстеження 1 запланованого завантаження',
@@ -513,6 +514,7 @@ const uk = {
settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.',
systemActionCountdown: '{{action}} через 10 секунд.',
systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.',
systemActionProceedAnyway: 'Продовжити попри це',
systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}',
downloadCompleteTitle: 'Завантаження завершено',
downloadCompleteBody: 'Завантаження {{fileName}} завершено.',
+2
View File
@@ -196,6 +196,7 @@ const zhCN = {
linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。',
validationDay: '至少为计划任务选择一天',
validationQueue: '至少为计划任务选择一个队列',
validationTime: '请输入 HH:MM 格式的有效时间',
validationStopTime: '停止时间必须晚于开始时间',
saved: '计划任务设置已保存',
trackingOne: '正在跟踪 1 个计划的下载',
@@ -513,6 +514,7 @@ const zhCN = {
settingsSaveFailed: '无法保存设置。请检查存储权限并重试。',
systemActionCountdown: '10 秒后{{action}}。',
systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。',
systemActionProceedAnyway: '仍然继续',
systemActionFailed: '计划的系统操作失败:{{detail}}',
downloadCompleteTitle: '下载完成',
downloadCompleteBody: '{{fileName}} 已下载完成。',
+1 -1
View File
@@ -83,7 +83,7 @@ type CommandMap = {
args: { preventSystemSleep: boolean; preventDisplaySleep: boolean };
result: void;
};
perform_system_action: { args: { action: PostQueueAction }; result: void };
perform_system_action: { args: { action: PostQueueAction; force: boolean }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void };
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
+11
View File
@@ -250,6 +250,17 @@ describe('useSettingsStore global speed limit persistence', () => {
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
});
it('rejects malformed limits before changing native or local state', async () => {
await expect(useSettingsStore.getState().setGlobalSpeedLimit('not-a-rate'))
.rejects.toThrow('Global speed limit is invalid');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'set_global_speed_limit',
expect.anything()
);
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
});
});
describe('useSettingsStore Torrent overall upload limit persistence', () => {
+9 -2
View File
@@ -517,11 +517,15 @@ export const useSettingsStore = create<SettingsState>()(
});
},
setGlobalSpeedLimit: async (limit) => {
const normalized = normalizeSpeedLimitForBackend(limit);
if (limit.trim() && !normalized) {
return Promise.reject(new Error('Global speed limit is invalid'));
}
await invoke('set_global_speed_limit', {
limit: normalizeSpeedLimitForBackend(limit)
limit: normalized
});
info('Settings updated: globalSpeedLimit');
set({ globalSpeedLimit: limit });
set({ globalSpeedLimit: normalized ?? '' });
},
setTorrentOverallUploadLimit: (limit) => {
const normalizedLimit = normalizeSpeedLimitForBackend(limit);
@@ -1026,6 +1030,9 @@ export const useSettingsStore = create<SettingsState>()(
torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string'
? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? ''
: currentState.torrentOverallUploadLimit,
globalSpeedLimit: typeof persisted.globalSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(persisted.globalSpeedLimit) ?? ''
: currentState.globalSpeedLimit,
perServerConnections: clampSettingInteger(
persisted.perServerConnections,
1,
+4
View File
@@ -15,6 +15,10 @@ const download = (id: string, status: DownloadItem['status']): DownloadItem => (
});
describe('schedulerCompletionState', () => {
it('does not treat an empty scheduler set as completed', () => {
expect(schedulerCompletionState([], [])).toBe('incomplete');
});
it('stays active while any tracked scheduler download can still progress', () => {
expect(schedulerCompletionState([
download('a', 'completed'),
+2
View File
@@ -7,6 +7,8 @@ export const schedulerCompletionState = (
downloads: DownloadItem[],
schedulerActiveDownloadIds: string[],
): SchedulerCompletionState => {
if (schedulerActiveDownloadIds.length === 0) return 'incomplete';
const scheduledItems = schedulerActiveDownloadIds.map(id =>
downloads.find(download => download.id === id)
);
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { beginSchedulerControl, isSchedulerControlCurrent } from './schedulerControl';
describe('scheduler control generation', () => {
it('invalidates an older asynchronous scheduler operation', () => {
const first = beginSchedulerControl();
expect(isSchedulerControlCurrent(first)).toBe(true);
const second = beginSchedulerControl();
expect(isSchedulerControlCurrent(first)).toBe(false);
expect(isSchedulerControlCurrent(second)).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
let schedulerControlGeneration = 0;
/**
* Start a new scheduler control lifecycle. A later manual pause or scheduler
* event invalidates earlier asynchronous queue operations before they can
* publish stale running state.
*/
export const beginSchedulerControl = (): number => {
schedulerControlGeneration += 1;
return schedulerControlGeneration;
};
export const isSchedulerControlCurrent = (generation: number): boolean =>
schedulerControlGeneration === generation;