fix(downloads): harden queue admission and live speed controls

This commit is contained in:
NimBold
2026-07-23 04:13:07 +03:30
parent b60818d3af
commit 3587fb0c0d
17 changed files with 1298 additions and 93 deletions
+205
View File
@@ -267,6 +267,166 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().renameQueue('queue-a', '')).toBe(false);
});
it('persists a queue concurrency override only after backend synchronization', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setQueueConcurrency('queue-a', 2);
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]);
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: 'main', maxConcurrent: null },
{ id: 'queue-a', maxConcurrent: 2 }
]
}
);
});
it('retains the previous queue concurrency after backend synchronization fails', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false, maxConcurrent: 2 }
]
});
vi.mocked(ipc.invokeCommand).mockRejectedValue(new Error('backend unavailable'));
await expect(useDownloadStore.getState().setQueueConcurrency('queue-a', 3))
.rejects.toThrow('backend unavailable');
expect(useDownloadStore.getState().queues[1].maxConcurrent).toBe(2);
});
it('rebases queue concurrency updates when queue state changes during IPC', async () => {
useDownloadStore.setState({
queues: [
{ id: 'main', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Downloads', isMain: false }
]
});
let releaseFirstSync!: () => void;
const firstSyncReleased = new Promise<void>(resolve => { releaseFirstSync = resolve; });
let syncCalls = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_queue_concurrency_limits') {
syncCalls += 1;
if (syncCalls === 1) await firstSyncReleased;
}
return undefined;
});
const update = useDownloadStore.getState().setQueueConcurrency('queue-a', 3);
await vi.waitFor(() => expect(syncCalls).toBe(1));
useDownloadStore.setState(state => ({
queues: state.queues.filter(queue => queue.id !== 'queue-a')
}));
releaseFirstSync();
await expect(update).rejects.toThrow('Queue no longer exists.');
expect(useDownloadStore.getState().queues).toEqual([
{ id: 'main', name: 'Main Queue', isMain: true }
]);
expect(syncCalls).toBe(2);
const configCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_queue_concurrency_limits');
expect(configCalls[1]).toEqual([
'set_queue_concurrency_limits',
{ limits: [{ id: 'main', maxConcurrent: null }] }
]);
});
it('updates an active normal download after applying a live speed limit', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', '2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_download_speed_limit', {
id: 'live-speed',
limit: '2M'
});
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
await useDownloadStore.getState().setDownloadSpeedLimit('live-speed', null);
const speedLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'set_download_speed_limit');
expect(speedLimitCalls[speedLimitCalls.length - 1]).toEqual(['set_download_speed_limit', {
id: 'live-speed',
limit: null
}]);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined();
});
it('rejects live speed changes for media and inactive downloads', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'media-speed', status: 'downloading', isMedia: true, speedLimit: '1M' },
{ id: 'paused-speed', status: 'paused', isMedia: false, speedLimit: '1M' }
] as any[]
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('media-speed', '2M'))
.rejects.toThrow('media downloads');
await expect(useDownloadStore.getState().setDownloadSpeedLimit('paused-speed', '2M'))
.rejects.toThrow('active download');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_download_speed_limit', expect.anything());
});
it('keeps the prior live speed limit when the backend rejects the update', async () => {
useDownloadStore.setState({
downloads: [{
id: 'live-speed-failure',
status: 'downloading',
isMedia: false,
speedLimit: '512K'
}] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'set_download_speed_limit') throw new Error('aria2 unavailable');
return undefined;
});
await expect(useDownloadStore.getState().setDownloadSpeedLimit('live-speed-failure', '2M'))
.rejects.toThrow('aria2 unavailable');
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('512K');
});
it('coalesces duplicate live speed updates for one download', async () => {
useDownloadStore.setState({
downloads: [{ id: 'live-speed-duplicate', status: 'downloading', isMedia: false }] as any[]
});
let releaseBackend!: () => void;
const backendFinished = new Promise<void>(resolve => { releaseBackend = resolve; });
vi.mocked(ipc.invokeCommand).mockImplementation(async () => backendFinished);
const first = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '2M');
const second = useDownloadStore.getState().setDownloadSpeedLimit('live-speed-duplicate', '3M');
expect(second).toBe(first);
releaseBackend();
await first;
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'set_download_speed_limit'))
.toHaveLength(1);
expect(useDownloadStore.getState().downloads[0].speedLimit).toBe('2M');
});
it('normalizes malformed persisted queues around one canonical main queue', () => {
expect(normalizePersistedQueues([
{ id: 'custom-a', name: ' Downloads ', isMain: false },
@@ -286,6 +446,47 @@ describe('useDownloadStore', () => {
]).queueIdRemap.get('legacy-main')).toBe('00000000-0000-0000-0000-000000000001');
});
it('keeps only valid persisted queue concurrency overrides', () => {
expect(normalizePersistedQueues([
{ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false, maxConcurrent: 0 },
{ id: 'large', name: 'Large', isMain: false, maxConcurrent: 13 },
{ id: 'null', name: 'Null', isMain: false, maxConcurrent: null }
])).toEqual([
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main', isMain: true, maxConcurrent: 4 },
{ id: 'valid', name: 'Valid', isMain: false, maxConcurrent: 12 },
{ id: 'zero', name: 'Zero', isMain: false },
{ id: 'large', name: 'Large', isMain: false },
{ id: 'null', name: 'Null', isMain: false }
]);
});
it('synchronizes normalized queue limits before startup resume can run', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
return [
JSON.stringify({ id: 'main', name: 'Main', isMain: true, maxConcurrent: 4 }),
JSON.stringify({ id: 'queue-a', name: 'Queue A', isMain: false, maxConcurrent: 0 })
];
}
if (cmd === 'db_get_all_downloads') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith(
'set_queue_concurrency_limits',
{
limits: [
{ id: '00000000-0000-0000-0000-000000000001', maxConcurrent: 4 },
{ id: 'queue-a', maxConcurrent: null }
]
}
);
});
it('remaps persisted downloads when queue records are malformed or missing', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') {
@@ -731,6 +932,10 @@ describe('useDownloadStore', () => {
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(calls.find(c => c[0] === 'resume_download')?.[1]).toEqual({
id: 'resume-generation',
queueId: 'MAIN'
});
expect(enqueueGeneration).toBe('1');
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
+134 -7
View File
@@ -28,6 +28,7 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
const queueReorderPromises = new Map<string, Promise<void>>();
const queueStartPromises = new Map<string, Promise<string[]>>();
const queueControlGenerations = new Map<string, number>();
let queueConfigurationQueue: Promise<void> = Promise.resolve();
type DownloadLifecycleOperation = {
kind: string;
promise: Promise<unknown>;
@@ -585,18 +586,39 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const DEFAULT_MAIN_QUEUE_NAME = 'Main Queue';
const MAX_QUEUE_CONCURRENT = 12;
const queueNameKey = (name: string): string => name.trim().toLowerCase();
export const normalizePersistedQueueState = (queues: Queue[]) => {
const normalizeQueueConcurrency = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isInteger(value)) return undefined;
return value >= 1 && value <= MAX_QUEUE_CONCURRENT ? value : undefined;
};
type PersistedQueue = Omit<Queue, 'maxConcurrent'> & {
maxConcurrent?: number | null;
};
const queueWithNormalizedConcurrency = (queue: PersistedQueue): Queue => {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
return maxConcurrent === undefined
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { id: queue.id, name: queue.name, isMain: queue.isMain, maxConcurrent };
};
export const normalizePersistedQueueState = (queues: PersistedQueue[]) => {
const validQueues = queues.filter(queue =>
queue && typeof queue.id === 'string' && typeof queue.name === 'string'
);
).map(queueWithNormalizedConcurrency);
const persistedMain = validQueues.find(queue => queue.id === MAIN_QUEUE_ID)
|| validQueues.find(queue => queue.isMain);
const persistedMainId = persistedMain?.id.trim();
const mainName = persistedMain?.name.trim() || DEFAULT_MAIN_QUEUE_NAME;
const normalized: Queue[] = [{ id: MAIN_QUEUE_ID, name: mainName, isMain: true }];
const normalizedMain: Queue = { id: MAIN_QUEUE_ID, name: mainName, isMain: true };
if (persistedMain?.maxConcurrent !== undefined) {
normalizedMain.maxConcurrent = persistedMain.maxConcurrent;
}
const normalized: Queue[] = [normalizedMain];
const seenIds = new Set([MAIN_QUEUE_ID]);
const seenNames = new Set([queueNameKey(mainName)]);
const queueIdRemap = new Map<string, string>();
@@ -620,15 +642,37 @@ export const normalizePersistedQueueState = (queues: Queue[]) => {
}
seenIds.add(id);
seenNames.add(queueNameKey(name));
normalized.push({ id, name, isMain: false });
const normalizedQueue = { id, name, isMain: false } as Queue;
if (queue.maxConcurrent !== undefined) {
const maxConcurrent = normalizeQueueConcurrency(queue.maxConcurrent);
if (maxConcurrent !== undefined) normalizedQueue.maxConcurrent = maxConcurrent;
}
normalized.push(normalizedQueue);
}
return { queues: normalized, queueIdRemap };
};
export const normalizePersistedQueues = (queues: Queue[]): Queue[] =>
export const normalizePersistedQueues = (queues: PersistedQueue[]): Queue[] =>
normalizePersistedQueueState(queues).queues;
const synchronizeQueueConcurrencyLimits = async (queues: Queue[]): Promise<void> => {
await invoke('set_queue_concurrency_limits', {
limits: queues.map(queue => ({
id: queue.id,
maxConcurrent: queue.maxConcurrent ?? null
}))
});
};
const sameQueueConcurrencyConfig = (left: Queue[], right: Queue[]): boolean =>
left.length === right.length && left.every((queue, index) => {
const other = right[index];
return other !== undefined
&& queue.id === other.id
&& (queue.maxConcurrent ?? null) === (other.maxConcurrent ?? null);
});
export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload;
export type AddDownloadAction =
@@ -706,6 +750,8 @@ interface DownloadState {
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
removeQueue: (id: string) => Promise<void>;
@@ -794,7 +840,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
lastTry: new Date().toISOString()
});
const resumedExisting = await invoke('resume_download', { id });
const resumedExisting = await invoke('resume_download', {
id,
queueId: targetItem.queueId || MAIN_QUEUE_ID
});
let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) {
@@ -1551,6 +1600,79 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}));
});
},
setDownloadSpeedLimit: (id, limit) => runDownloadLifecycleOperation(
id,
'speed-limit',
async () => {
await waitForPendingStartupResume();
const item = get().downloads.find(download => download.id === id);
if (!item) throw new Error('Download no longer exists.');
if (item.isMedia) {
throw new Error('Live speed control is unavailable for media downloads.');
}
if (!['downloading', 'retrying'].includes(item.status)) {
throw new Error('Live speed control requires an active download.');
}
const trimmed = limit?.trim() || '';
const normalizedLimit = trimmed
? normalizeSpeedLimitForBackend(trimmed)
: null;
if (trimmed && normalizedLimit === null) {
throw new Error('Enter a valid speed limit.');
}
await invoke('set_download_speed_limit', {
id,
limit: normalizedLimit
});
if (get().downloads.some(download => download.id === id)) {
get().updateDownload(id, { speedLimit: normalizedLimit ?? undefined });
}
},
true,
preemptDispatch
),
setQueueConcurrency: (id, maxConcurrent) => {
const operation = queueConfigurationQueue.then(async () => {
if (
maxConcurrent !== null
&& (!Number.isInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > MAX_QUEUE_CONCURRENT)
) {
throw new Error('Queue concurrency must be between 1 and 12.');
}
const currentQueues = get().queues;
if (!currentQueues.some(queue => queue.id === id)) {
throw new Error('Queue no longer exists.');
}
const nextQueues = currentQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
await synchronizeQueueConcurrencyLimits(nextQueues);
const latestQueues = get().queues;
if (!latestQueues.some(queue => queue.id === id)) {
await synchronizeQueueConcurrencyLimits(latestQueues);
throw new Error('Queue no longer exists.');
}
const rebasedQueues = latestQueues.map(queue =>
queue.id === id
? maxConcurrent === null
? { id: queue.id, name: queue.name, isMain: queue.isMain }
: { ...queue, maxConcurrent }
: queue
);
if (!sameQueueConcurrencyConfig(nextQueues, rebasedQueues)) {
await synchronizeQueueConcurrencyLimits(rebasedQueues);
}
set({ queues: rebasedQueues });
});
queueConfigurationQueue = operation.then(() => undefined, () => undefined);
return operation;
},
addQueue: (name) => {
const normalizedName = name.trim();
if (!normalizedName) return false;
@@ -1772,7 +1894,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
try {
const persistedQueues = (await invoke('db_get_all_queues')).flatMap(value => {
try {
return [JSON.parse(value) as Queue];
return [JSON.parse(value) as PersistedQueue];
} catch {
console.warn('Skipping malformed persisted queue record during startup');
return [];
@@ -1796,6 +1918,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
? normalizeQueuePositions(downloads)
: state.downloads
}));
// The backend dispatcher is live before the frontend finishes startup.
// Synchronize the normalized queue policy before any saved download is
// allowed to claim a permit.
await synchronizeQueueConcurrencyLimits(queues);
// Reset interrupted active downloads to queued.
set((state) => ({