fix(downloads): harden queue and lifecycle synchronization

Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
This commit is contained in:
NimBold
2026-07-14 18:29:14 +03:30
parent 2d9eed99d5
commit e07182fbf2
15 changed files with 1074 additions and 321 deletions
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import {
parseDownloadEta,
parseDownloadSize,
sortDownloads,
type DownloadSortConfig
} from './downloadTableSorting';
import { redactDownloadForPersistence } from './downloads';
const item = (id: string, overrides: Partial<DownloadItem> = {}): DownloadItem => ({
id,
url: `https://example.test/${id}`,
fileName: id,
status: 'queued',
category: 'Other',
dateAdded: '2026-07-14T00:00:00.000Z',
...overrides
});
const sortedIds = (downloads: DownloadItem[], config: DownloadSortConfig): string[] =>
sortDownloads(downloads, config).map(download => download.id);
describe('download table sorting', () => {
it('compares human-readable sizes by bytes instead of their leading number', () => {
expect(parseDownloadSize('1 MB')).toBe(1024 ** 2);
expect(sortedIds([
item('one-mb', { size: '1 MB' }),
item('900-kb', { size: '900 KB' }),
item('two-mb', { size: '2 MB' })
], { column: 'Size', direction: 'asc' })).toEqual(['900-kb', 'one-mb', 'two-mb']);
});
it('supports clock and unit ETA values and keeps unknown values last', () => {
expect(parseDownloadEta('01:02:03')).toBe(3723);
expect(parseDownloadEta('2m 5s')).toBe(125);
expect(sortedIds([
item('unknown', { eta: '-' }),
item('long', { eta: '2m' }),
item('short', { eta: '10s' })
], { column: 'ETA', direction: 'asc' })).toEqual(['short', 'long', 'unknown']);
});
it('sorts descending on the second click without reverting to an unsorted list', () => {
const downloads = [item('b', { fileName: 'Beta' }), item('a', { fileName: 'Alpha' })];
expect(sortedIds(downloads, { column: 'File Name', direction: 'asc' })).toEqual(['a', 'b']);
expect(sortedIds(downloads, { column: 'File Name', direction: 'desc' })).toEqual(['b', 'a']);
});
it('does not persist volatile progress fields', () => {
const persisted = redactDownloadForPersistence(item('volatile', {
fraction: 0.75,
speed: '1 MB/s',
eta: '10s'
}));
expect(persisted.fraction).toBeUndefined();
expect(persisted.speed).toBeUndefined();
expect(persisted.eta).toBeUndefined();
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { DownloadItem } from '../bindings/DownloadItem';
export type DownloadSortColumn = 'File Name' | 'Size' | 'Status' | 'Speed' | 'ETA' | 'Date Added';
export type DownloadSortDirection = 'asc' | 'desc';
export type DownloadSortConfig = {
column: DownloadSortColumn;
direction: DownloadSortDirection;
};
const SIZE_UNITS: Record<string, number> = {
B: 1,
KB: 1024,
KIB: 1024,
MB: 1024 ** 2,
MIB: 1024 ** 2,
GB: 1024 ** 3,
GIB: 1024 ** 3,
TB: 1024 ** 4,
TIB: 1024 ** 4,
};
const valueOrNull = (value?: string): string | null => {
const trimmed = value?.trim();
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
};
const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
const normalized = valueOrNull(value);
if (!normalized) return null;
const match = normalized.match(/^([\d.,]+)\s*([KMGT]?I?B)(?:\/s)?$/i);
if (!match) {
const number = Number(normalized.replace(/,/g, ''));
return Number.isFinite(number) ? number : null;
}
const amount = Number(match[1].replace(/,/g, ''));
const multiplier = units[match[2].toUpperCase()];
return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
};
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
export const parseDownloadSpeed = (value?: string): number | null =>
parseUnitValue(value, SIZE_UNITS);
export const parseDownloadEta = (value?: string): number | null => {
const normalized = valueOrNull(value);
if (!normalized) return null;
const clockParts = normalized.split(':').map(part => Number(part));
if (clockParts.length >= 2 && clockParts.every(Number.isFinite)) {
return clockParts.reduce((total, part, index) => total + part * 60 ** (clockParts.length - index - 1), 0);
}
let seconds = 0;
let matched = false;
for (const [pattern, multiplier] of [[/(\d+(?:\.\d+)?)h/i, 3600], [/(\d+(?:\.\d+)?)m/i, 60], [/(\d+(?:\.\d+)?)s/i, 1]] as const) {
const match = normalized.match(pattern);
if (match) {
seconds += Number(match[1]) * multiplier;
matched = true;
}
}
return matched && Number.isFinite(seconds) ? seconds : null;
};
const compareValues = (left: string | number | null, right: string | number | null): number => {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
};
const parseDownloadDate = (value?: string): number | null => {
if (!value?.trim()) return null;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
};
export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortConfig): DownloadItem[] => {
const sorted = [...downloads].sort((left, right) => {
let comparison: number;
switch (config.column) {
case 'File Name':
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
break;
case 'Size':
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
break;
case 'Status':
comparison = compareValues(left.status, right.status);
break;
case 'Speed':
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
break;
case 'ETA':
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
break;
case 'Date Added':
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
break;
}
if (comparison === 0) comparison = left.id.localeCompare(right.id);
return config.direction === 'asc' ? comparison : -comparison;
});
return sorted;
};
+5
View File
@@ -36,6 +36,10 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
ACTIVE_DOWNLOAD_STATUSES.has(status);
/** Transfer states that consume a worker/permit. Queued is intentionally excluded. */
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
@@ -128,6 +132,7 @@ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
*/
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
const copy: DownloadItem = { ...item };
delete copy.fraction;
delete copy.speed;
delete copy.eta;
for (const field of DOWNLOAD_SECRET_FIELDS) {