fix(tools): harden scheduler limits and logs

Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
This commit is contained in:
NimBold
2026-07-15 03:00:46 +03:30
parent 45bbca0515
commit 80a29356e0
10 changed files with 290 additions and 74 deletions
+72 -20
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
@@ -27,6 +27,11 @@ export default function LogsView() {
const scrollRef = useRef<HTMLDivElement>(null);
const liveBatchRef = useRef<LogEntry[]>([]);
const liveFrameRef = useRef<number | null>(null);
const clearGenerationRef = useRef(0);
const clearInFlightRef = useRef<Promise<void> | null>(null);
const toggleInFlightRef = useRef<Promise<void> | null>(null);
const [isClearing, setIsClearing] = useState(false);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
@@ -36,16 +41,17 @@ export default function LogsView() {
useEffect(() => {
if (!pageVisible) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
return;
}
if (!logsEnabled) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
}
let active = true;
let initialized = false;
const initGeneration = clearGenerationRef.current;
let pendingLiveEntries: LogEntry[] = [];
let unlistenPromise: Promise<() => void> | undefined;
@@ -78,9 +84,9 @@ export default function LogsView() {
});
await unlistenPromise;
if (!active) return;
await invoke('set_log_stream_active', { active: true });
await setLogStreamActive(true);
if (!active) {
await invoke('set_log_stream_active', { active: false }).catch(console.error);
await setLogStreamActive(false).catch(console.error);
return;
}
}
@@ -89,6 +95,10 @@ export default function LogsView() {
if (!active) return;
const snapshot = lines.map(persistedLogEntry);
initialized = true;
if (initGeneration !== clearGenerationRef.current) {
pendingLiveEntries = [];
return;
}
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
pendingLiveEntries = [];
setLogs(caughtUpLogs);
@@ -106,7 +116,7 @@ export default function LogsView() {
liveFrameRef.current = null;
}
if (logsEnabled) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
}
if (unlistenPromise) {
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
@@ -170,23 +180,63 @@ export default function LogsView() {
};
const handleClear = async () => {
liveBatchRef.current = [];
if (liveFrameRef.current !== null) {
window.cancelAnimationFrame(liveFrameRef.current);
liveFrameRef.current = null;
if (clearInFlightRef.current) return;
const clearOperation = invoke('clear_logs');
clearInFlightRef.current = clearOperation;
setIsClearing(true);
try {
await clearOperation;
clearGenerationRef.current += 1;
liveBatchRef.current = [];
if (liveFrameRef.current !== null) {
window.cancelAnimationFrame(liveFrameRef.current);
liveFrameRef.current = null;
}
setLogs([]);
addToast({ message: 'Logs cleared', variant: 'info' });
} catch (error) {
addToast({
message: `Could not clear logs: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
if (clearInFlightRef.current === clearOperation) {
clearInFlightRef.current = null;
}
setIsClearing(false);
}
setLogs([]);
await invoke('clear_logs').catch(console.error);
};
const handleToggleLogging = async () => {
if (toggleInFlightRef.current) return;
const nextEnabled = !logsEnabled;
setLogsEnabled(nextEnabled);
await setLogPaused(!nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
variant: 'success'
});
const toggleOperation = (async () => {
await setLogPaused(!nextEnabled);
setLogsEnabled(nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
variant: 'success'
});
})();
toggleInFlightRef.current = toggleOperation;
setIsToggling(true);
try {
await toggleOperation;
} catch (error) {
addToast({
message: `Could not update diagnostic logging: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
if (toggleInFlightRef.current === toggleOperation) {
toggleInFlightRef.current = null;
}
setIsToggling(false);
}
};
const severityClass = (level: string) => {
@@ -233,14 +283,16 @@ export default function LogsView() {
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
<button
onClick={handleToggleLogging}
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
disabled={isToggling}
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
>
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={handleClear}
className="app-icon-button"
disabled={isClearing}
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
title="Clear displayed logs"
>
<Trash2 size={14} />
+4 -1
View File
@@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
import { isActiveDownloadStatus } from '../utils/downloads';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
@@ -137,6 +138,7 @@ export default function SchedulerView() {
};
const runNow = async () => {
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
@@ -144,8 +146,9 @@ export default function SchedulerView() {
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
previouslyTrackedIds.has(download.id) &&
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
isActiveDownloadStatus(download.status)
)
.map(download => download.id);
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { displayValueFromPresetBase, presetBaseFromDisplayValue } from './SpeedLimiterView';
describe('SpeedLimiterView speed conversions', () => {
it('converts KB/s and MB/s using binary units consistently', () => {
expect(presetBaseFromDisplayValue(1024, 'KB/s')).toBe(1);
expect(displayValueFromPresetBase(1, 'KB/s')).toBe(1024);
expect(presetBaseFromDisplayValue(5, 'MB/s')).toBe(5);
expect(displayValueFromPresetBase(5, 'MB/s')).toBe(5);
});
});
+40 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
@@ -9,7 +9,7 @@ type SpeedUnit = 'KB/s' | 'MB/s';
const MAX_LIMIT_KIB = 10_485_760;
const MAX_LIMIT_MB = 10240;
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
export function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const valueKiB = match
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
@@ -29,12 +29,12 @@ function sanitizePresetValues(values: number[]): number[] {
return Array.from(new Set(cleaned)).sort((a, b) => a - b);
}
function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : value / 1000;
export function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : value / 1024;
}
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1000);
export function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1024);
}
function formatPresetValue(value: number): string {
@@ -54,6 +54,7 @@ export default function SpeedLimiterView() {
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
const { addToast } = useToast();
const savingRef = useRef(false);
const presetValues = useMemo(
() => sanitizePresetValues(speedLimitPresetValues),
[speedLimitPresetValues]
@@ -68,15 +69,31 @@ export default function SpeedLimiterView() {
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
const save = () => {
const [isSaving, setIsSaving] = useState(false);
const save = async () => {
if (savingRef.current) return;
savingRef.current = true;
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
const valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
});
setIsSaving(true);
try {
await setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setLastCustomSpeedLimitKiB(valueKiB);
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
});
} catch (error) {
addToast({
message: `Could not save global speed limit: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
savingRef.current = false;
setIsSaving(false);
}
};
const preset = (presetValue: number) => {
@@ -118,7 +135,8 @@ export default function SpeedLimiterView() {
<div className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary select-none">
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
disabled={isSaving}
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
aria-checked={enabled}
role="switch"
>
@@ -133,7 +151,7 @@ export default function SpeedLimiterView() {
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
</span>
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> Save Limit
</button>
</div>
@@ -144,7 +162,7 @@ export default function SpeedLimiterView() {
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers and yt-dlp media downloads. Per-download limits still take precedence.
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
</p>
<div className="mt-6 flex items-center gap-3">
@@ -152,7 +170,7 @@ export default function SpeedLimiterView() {
type="number"
min="1"
value={value}
disabled={!enabled}
disabled={!enabled || isSaving}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
className="app-control w-28 px-3 py-2 text-right font-mono"
/>
@@ -161,7 +179,7 @@ export default function SpeedLimiterView() {
<button
key={option}
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={() => setUnit(option)}
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
@@ -187,7 +205,7 @@ export default function SpeedLimiterView() {
>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={() => preset(presetValue)}
className="h-full flex-1 px-3 text-left disabled:opacity-50"
>
@@ -195,7 +213,7 @@ export default function SpeedLimiterView() {
</button>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={() => removePreset(presetValue)}
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
@@ -211,7 +229,7 @@ export default function SpeedLimiterView() {
type="number"
min="1"
value={customPresetValue}
disabled={!enabled}
disabled={!enabled || isSaving}
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
aria-label={`Custom preset in ${unit}`}
@@ -219,7 +237,7 @@ export default function SpeedLimiterView() {
<span className="text-[11px] text-text-muted">{unit}</span>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"