fix(downloads): surface allocation and retain terminal progress

- emit native allocation state around Aria2 preallocation
- preflight batch destinations before backend admission
- preserve exact progress across retries and terminal states
- fence allocation and progress across pauses, retries, and stale GIDs
This commit is contained in:
NimBold
2026-08-21 01:33:13 +03:30
parent c355e99913
commit 2d265ce7c8
12 changed files with 1652 additions and 166 deletions
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadAllocationEvent = { id: string, pending: boolean, lifecycleGeneration: string, };
+2 -1
View File
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DownloadErrorKind } from "./DownloadErrorKind";
import type { DownloadStateProgress } from "./DownloadStateProgress";
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, };
export type DownloadStateEvent = { id: string, status: string, error: string | null, errorKind?: DownloadErrorKind, resolverFallback?: boolean, fileName?: string, destination?: string, torrentSeedRemaining?: number, progress?: DownloadStateProgress, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStateProgress = { fraction: number, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, };
+2
View File
@@ -4,6 +4,7 @@ import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn
import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
import type { DownloadAllocationEvent } from './bindings/DownloadAllocationEvent';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
import type { MediaMetadata } from './bindings/MediaMetadata';
@@ -200,6 +201,7 @@ export function invokeCommand<K extends CommandName>(
type EventMap = {
'schedule-trigger': { action: 'start' | 'stop'; key: string };
'download-progress': DownloadProgressEvent;
'download-allocation': DownloadAllocationEvent;
'download-state': DownloadStateEvent;
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
'download-complete': string;
+73
View File
@@ -3,15 +3,67 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
retainedProgressMap: Record<string, DownloadProgressEvent>;
moveProgressMap: Record<string, number>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
resetDownloadProgress: (id: string) => void;
setMoveProgress: (id: string, fraction: number) => void;
clearMoveProgress: (id: string) => void;
}
const finiteNonNegative = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
const retainProgressSnapshot = (
previous: DownloadProgressEvent | undefined,
next: DownloadProgressEvent,
): DownloadProgressEvent => {
const previousDownloaded = finiteNonNegative(previous?.downloaded_bytes)
? previous.downloaded_bytes
: undefined;
const nextDownloaded = finiteNonNegative(next.downloaded_bytes)
? next.downloaded_bytes
: undefined;
const downloadedBytes = previousDownloaded === undefined
? nextDownloaded
: nextDownloaded === undefined
? previousDownloaded
: Math.max(previousDownloaded, nextDownloaded);
const exactTotal = [next, previous]
.find(snapshot => snapshot?.total_is_estimate === false
&& finiteNonNegative(snapshot.total_bytes))
?.total_bytes;
const totalBytes = exactTotal
?? (finiteNonNegative(next.total_bytes)
? next.total_bytes
: finiteNonNegative(previous?.total_bytes)
? previous.total_bytes
: undefined);
const totalIsEstimate = exactTotal !== undefined
? false
: next.total_is_estimate ?? previous?.total_is_estimate;
const fractions = [previous?.fraction, next.fraction]
.filter(finiteNonNegative);
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
}
return {
...next,
fraction: fractions.length > 0
? Math.min(1, Math.max(0, Math.max(...fractions)))
: next.fraction,
...(downloadedBytes !== undefined ? { downloaded_bytes: downloadedBytes } : {}),
...(totalBytes !== undefined ? { total_bytes: totalBytes } : {}),
...(totalIsEstimate !== undefined ? { total_is_estimate: totalIsEstimate } : {})
};
};
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
progressMap: {},
retainedProgressMap: {},
moveProgressMap: {},
updateDownloadProgress: (id, payload) =>
set((state) => ({
@@ -19,6 +71,10 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
...state.progressMap,
[id]: payload,
},
retainedProgressMap: {
...state.retainedProgressMap,
[id]: retainProgressSnapshot(state.retainedProgressMap[id], payload),
},
})),
clearDownloadProgress: (id) =>
set((state) => {
@@ -29,6 +85,23 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
delete nextMove[id];
return { progressMap: next, moveProgressMap: nextMove };
}),
resetDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)
&& !(id in state.retainedProgressMap)
&& !(id in state.moveProgressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
const nextRetained = { ...state.retainedProgressMap };
delete nextRetained[id];
const nextMove = { ...state.moveProgressMap };
delete nextMove[id];
return {
progressMap: next,
retainedProgressMap: nextRetained,
moveProgressMap: nextMove
};
}),
setMoveProgress: (id, fraction) =>
set((state) => ({
moveProgressMap: { ...state.moveProgressMap, [id]: fraction }
+261 -3
View File
@@ -18,7 +18,7 @@ describe('useDownloadProgressStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
useDownloadProgressStore.setState({ progressMap: {}, moveProgressMap: {} });
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
clearDownloadControlIntents();
});
@@ -44,7 +44,7 @@ describe('useDownloadProgressStore', () => {
const first = initDownloadListener();
const second = initDownloadListener();
expect(ipc.listenEvent).toHaveBeenCalledTimes(4);
expect(ipc.listenEvent).toHaveBeenCalledTimes(5);
const releaseFirst = await first;
const releaseSecond = await second;
@@ -52,7 +52,7 @@ describe('useDownloadProgressStore', () => {
expect(unlisten).not.toHaveBeenCalled();
releaseSecond();
expect(unlisten).toHaveBeenCalledTimes(4);
expect(unlisten).toHaveBeenCalledTimes(5);
});
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
@@ -92,6 +92,88 @@ describe('useDownloadProgressStore', () => {
release();
});
it('projects native allocation events after admission and ignores stale generations', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'native-allocation',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-allocation']({ payload: {
id: 'native-allocation',
pending: true,
lifecycleGeneration: '0'
} });
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
handlers['download-allocation']({ payload: {
id: 'native-allocation',
pending: false,
lifecycleGeneration: '1'
} });
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(true);
handlers['download-allocation']({ payload: {
id: 'native-allocation',
pending: false,
lifecycleGeneration: '0'
} });
expect(useDownloadStore.getState().allocationPendingIds.has('native-allocation')).toBe(false);
release();
});
it('retains a native allocation marker received before row hydration', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [],
allocationPendingIds: new Set()
});
const release = await initDownloadListener();
handlers['download-allocation']({ payload: {
id: 'hydrating-allocation',
pending: true,
lifecycleGeneration: '0'
} });
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
useDownloadStore.setState({
downloads: [{
id: 'hydrating-allocation',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(true);
handlers['download-allocation']({ payload: {
id: 'hydrating-allocation',
pending: false,
lifecycleGeneration: '0'
} });
expect(useDownloadStore.getState().allocationPendingIds.has('hydrating-allocation')).toBe(false);
release();
});
it('applies the authoritative destination carried by Torrent move completion', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
@@ -470,6 +552,182 @@ describe('useDownloadProgressStore', () => {
expect(row.totalBytes).toBe(10240);
expect(row.totalIsEstimate).toBe(true);
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
expect(useDownloadProgressStore.getState().retainedProgressMap.snapshot).toMatchObject({
fraction: 0.8,
downloaded_bytes: 8192,
total_bytes: 10240
});
release();
});
it('retains progress for failed and paused rows when the live entry is absent', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'terminal-progress',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'terminal-progress',
fraction: 0.7,
speed: '1 MB/s',
eta: '2s',
size: '10 MB',
size_is_final: false,
downloaded_bytes: 7000,
total_bytes: 10000,
total_is_estimate: false
} });
handlers['download-state']({ payload: {
id: 'terminal-progress',
status: 'paused',
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
fraction: 0.7,
downloadedBytes: 7000
});
useDownloadStore.setState(state => ({
downloads: state.downloads.map(download => ({ ...download, status: 'downloading' as const }))
}));
handlers['download-state']({ payload: {
id: 'terminal-progress',
status: 'failed',
error: 'network stopped',
progress: {
fraction: 0.8,
downloadedBytes: 8000,
totalBytes: 10000,
totalIsEstimate: false
}
} });
expect(useDownloadProgressStore.getState().progressMap['terminal-progress']).toBeUndefined();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'failed',
fraction: 0.8,
downloadedBytes: 8000,
totalBytes: 10000,
totalIsEstimate: false
});
release();
});
it('keeps retained bytes when a paused GID resumes through a queued state', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'same-gid-resume',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'same-gid-resume',
fraction: 0.6,
speed: '1 MB/s',
eta: '4s',
size: '10 KB',
size_is_final: false,
downloaded_bytes: 6000,
total_bytes: 10000,
total_is_estimate: false
} });
useDownloadStore.setState(state => ({
downloads: state.downloads.map(download => ({
...download,
status: 'queued' as const
}))
}));
handlers['download-state']({ payload: {
id: 'same-gid-resume',
status: 'queued'
} });
expect(useDownloadProgressStore.getState().progressMap['same-gid-resume']).toBeUndefined();
expect(useDownloadProgressStore.getState().retainedProgressMap['same-gid-resume']).toMatchObject({
downloaded_bytes: 6000,
total_bytes: 10000
});
release();
});
it('keeps the greatest retained byte count across retry frames', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'retry-progress',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
const progress = (fraction: number, downloadedBytes: number) => handlers['download-progress']({ payload: {
id: 'retry-progress',
fraction,
speed: '1 MB/s',
eta: '2s',
size: '10 KB',
size_is_final: false,
downloaded_bytes: downloadedBytes,
total_bytes: 10000,
total_is_estimate: false
} });
progress(0.8, 8000);
handlers['download-state']({ payload: {
id: 'retry-progress',
status: 'retrying',
error: 'network dropped'
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'retrying',
fraction: 0.8,
downloadedBytes: 8000,
totalBytes: 10000,
totalIsEstimate: false
});
useDownloadStore.getState().updateDownload('retry-progress', { status: 'downloading' });
progress(0.1, 1000);
handlers['download-state']({ payload: {
id: 'retry-progress',
status: 'failed',
error: 'retry exhausted'
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
fraction: 0.8,
downloadedBytes: 8000,
totalBytes: 10000,
totalIsEstimate: false
});
release();
});
+142 -13
View File
@@ -8,6 +8,7 @@ import { useDownloadProgressStore } from './downloadProgressStore';
import {
clearDownloadControlIntent,
commitDownloadState,
currentDownloadLifecycleGeneration,
downloadControlIntentFor,
hasStaleTemporaryMediaEstimate,
useDownloadStore
@@ -16,15 +17,94 @@ import {
export { useDownloadProgressStore } from './downloadProgressStore';
let unlistenProgress: UnlistenFn | null = null;
let unlistenAllocation: UnlistenFn | null = null;
let unlistenState: UnlistenFn | null = null;
let unlistenMoveProgress: UnlistenFn | null = null;
let unlistenTray: UnlistenFn | null = null;
let listenerSetup: Promise<void> | null = null;
let listenerConsumers = 0;
type ProgressFields = {
fraction?: number;
downloadedBytes?: number;
totalBytes?: number;
totalIsEstimate?: boolean;
};
const finiteNonNegative = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
const progressFields = (source: unknown): ProgressFields => {
if (!source || typeof source !== 'object') return {};
const value = source as Record<string, unknown>;
const downloadedBytes = value.downloadedBytes ?? value.downloaded_bytes;
const totalBytes = value.totalBytes ?? value.total_bytes;
const totalIsEstimate = value.totalIsEstimate ?? value.total_is_estimate;
return {
...(finiteNonNegative(value.fraction) ? { fraction: value.fraction } : {}),
...(finiteNonNegative(downloadedBytes) ? { downloadedBytes } : {}),
...(finiteNonNegative(totalBytes) ? { totalBytes } : {}),
...(typeof totalIsEstimate === 'boolean' ? { totalIsEstimate } : {})
};
};
const mergeTerminalProgress = (
current: DownloadItem,
status: DownloadStatus,
nativeSnapshot: unknown,
retainedSnapshot: unknown,
liveSnapshot: unknown
): ProgressFields => {
const ordered = [nativeSnapshot, retainedSnapshot, liveSnapshot]
.map(progressFields);
const row = progressFields({
fraction: current.fraction,
downloadedBytes: current.downloadedBytes,
totalBytes: current.totalBytes,
totalIsEstimate: current.totalIsEstimate
});
const all = [...ordered, row];
const downloadedCandidates = all
.map(snapshot => snapshot.downloadedBytes)
.filter((value): value is number => finiteNonNegative(value));
const downloadedBytes = downloadedCandidates.length > 0
? Math.max(...downloadedCandidates)
: undefined;
const exactTotals = all
.filter(snapshot => snapshot.totalIsEstimate === false && finiteNonNegative(snapshot.totalBytes))
.map(snapshot => snapshot.totalBytes!);
const anyTotals = all
.map(snapshot => snapshot.totalBytes)
.filter((value): value is number => finiteNonNegative(value));
const totalBytes = exactTotals[0] ?? anyTotals[0];
const fractions = all
.map(snapshot => snapshot.fraction)
.filter((value): value is number => finiteNonNegative(value));
if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) {
fractions.push(Math.min(downloadedBytes, totalBytes) / totalBytes);
}
if (status === 'completed') fractions.push(1);
const fraction = fractions.length > 0
? Math.min(1, Math.max(0, Math.max(...fractions)))
: undefined;
return {
...(fraction !== undefined ? { fraction } : {}),
...(downloadedBytes !== undefined ? { downloadedBytes } : {}),
...(totalBytes !== undefined ? { totalBytes } : {}),
...(exactTotals.length > 0
? { totalIsEstimate: false }
: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)?.totalIsEstimate !== undefined
? { totalIsEstimate: ordered.find(snapshot => snapshot.totalIsEstimate !== undefined)!.totalIsEstimate }
: {})
};
};
const disposeDownloadListeners = () => {
unlistenProgress?.();
unlistenProgress = null;
unlistenAllocation?.();
unlistenAllocation = null;
unlistenState?.();
unlistenState = null;
unlistenMoveProgress?.();
@@ -43,7 +123,7 @@ const startDownloadListeners = async () => {
if (!current) {
// A removed row can still have one queued sidecar event in flight.
// Do not let that event recreate an orphaned progress entry.
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
return;
}
// A sidecar can flush one last progress chunk after a pause, failure,
@@ -112,12 +192,39 @@ const startDownloadListeners = async () => {
mainStore.updateDownload(payload.id, updates);
}
}),
listen('download-allocation', (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(download => download.id === payload.id);
if (!current) {
// Keep a validated native marker until persisted startup state or a
// just-admitted row is projected. Dropping it here makes allocation
// invisible when the event wins the hydration race.
mainStore.setAllocationPending(
payload.id,
payload.pending,
payload.lifecycleGeneration
);
return;
}
// Allocation events are native lifecycle markers. A late marker from an
// older GID/queue lifecycle must never hide the current lifecycle's
// phase or clear its pending state.
if (payload.lifecycleGeneration !== currentDownloadLifecycleGeneration(payload.id)) {
return;
}
mainStore.setAllocationPending(
payload.id,
payload.pending,
payload.lifecycleGeneration
);
}),
listen('download-state', async (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (!current) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
useDownloadProgressStore.getState().resetDownloadProgress(payload.id);
return;
}
const status = payload.status as DownloadStatus;
@@ -184,8 +291,26 @@ const startDownloadListeners = async () => {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
const progressState = useDownloadProgressStore.getState();
const liveProgress = progressState.progressMap[payload.id];
const retainedProgress = progressState.retainedProgressMap[payload.id];
const isTerminalOrPaused = ['completed', 'failed', 'paused', 'retrying', 'waitingToSeed'].includes(status);
const terminalProgress = isTerminalOrPaused
? mergeTerminalProgress(
current,
status,
payload.progress,
retainedProgress,
liveProgress
)
: undefined;
if (status === 'queued') {
// A queued event can represent either a genuinely new admission or a
// same-GID resume of a paused Aria2 transfer. Lifecycle-changing
// callers reset the retained snapshot before admission; this event
// only ends the old live frame so a same-GID resume keeps its bytes.
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
} else if (['retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const moveRestoreStatus = status === 'moving'
@@ -196,16 +321,18 @@ const startDownloadListeners = async () => {
const updates: Partial<DownloadItem> = {
status,
torrentMoveRestoreStatus: moveRestoreStatus,
...(progress ? {
fraction: progress.fraction,
...(progress.downloaded_bytes != null
? { downloadedBytes: progress.downloaded_bytes }
...(terminalProgress ? {
...(terminalProgress.fraction !== undefined
? { fraction: terminalProgress.fraction }
: {}),
...(progress.total_bytes != null
? { totalBytes: progress.total_bytes }
...(terminalProgress.downloadedBytes !== undefined
? { downloadedBytes: terminalProgress.downloadedBytes }
: {}),
...(progress.total_is_estimate != null
? { totalIsEstimate: progress.total_is_estimate }
...(terminalProgress.totalBytes !== undefined
? { totalBytes: terminalProgress.totalBytes }
: {}),
...(terminalProgress.totalIsEstimate !== undefined
? { totalIsEstimate: terminalProgress.totalIsEstimate }
: {})
} : {}),
...(payload.error ? {
@@ -326,13 +453,15 @@ const startDownloadListeners = async () => {
throw failedRegistration.reason;
}
const [progress, state, moveProgress, tray] = registrations as [
const [progress, allocation, state, moveProgress, tray] = registrations as [
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
];
unlistenProgress = progress.value;
unlistenAllocation = allocation.value;
unlistenState = state.value;
unlistenMoveProgress = moveProgress.value;
unlistenTray = tray.value;
+54 -7
View File
@@ -108,7 +108,7 @@ describe('useDownloadStore', () => {
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
});
useDownloadProgressStore.setState({ progressMap: {} });
useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} });
});
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
@@ -1337,7 +1337,7 @@ describe('useDownloadStore', () => {
).toHaveLength(2);
});
it('exposes an indeterminate allocation phase while normal enqueue is blocked', async () => {
it('does not expose allocation while admission is merely blocked', async () => {
useDownloadStore.setState({
downloads: [{
id: 'allocation-phase',
@@ -1365,15 +1365,19 @@ describe('useDownloadStore', () => {
const dispatch = dispatchItem('allocation-phase');
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'allocation-phase' }) })
);
});
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
resolveEnqueue({ id: 'allocation-phase', filename: 'file.bin' });
await expect(dispatch).resolves.toBe(true);
expect(useDownloadStore.getState().allocationPendingIds.has('allocation-phase')).toBe(false);
});
it('exposes allocation phase for a preallocated Torrent and strips metadata credentials', async () => {
it('does not expose Torrent allocation while admission is merely blocked and strips metadata credentials', async () => {
useDownloadStore.setState({
downloads: [{
id: 'torrent-allocation-phase',
@@ -1411,8 +1415,12 @@ describe('useDownloadStore', () => {
const dispatch = dispatchItem('torrent-allocation-phase');
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(true);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({ item: expect.objectContaining({ id: 'torrent-allocation-phase' }) })
);
});
expect(useDownloadStore.getState().allocationPendingIds.has('torrent-allocation-phase')).toBe(false);
resolveEnqueue({ id: 'torrent-allocation-phase', filename: 'payload' });
await expect(dispatch).resolves.toBe(true);
@@ -2555,7 +2563,46 @@ describe('useDownloadStore', () => {
});
});
it('shows and clears allocation phase for a blocked startup Torrent batch', async () => {
it('keeps startup destination permission failures retryable without backend registration', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
if (cmd === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-destination-access',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/protected',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (cmd === 'enqueue_many') {
return [{
id: 'startup-destination-access',
success: false,
error: 'destination access retryable: grant Firelink access to the selected folder and retry'
}];
}
if (cmd === 'get_pending_order') return [];
return undefined;
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'ready',
hasBeenDispatched: false,
lastErrorKind: 'destinationAccess',
lastError: 'grant Firelink access to the selected folder and retry'
});
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-destination-access')).toBe(false);
});
it('does not show allocation for a startup Torrent batch while it is merely queued', async () => {
let releaseEnqueue!: (value: Array<{ id: string; success: boolean; filename: string }>) => void;
const enqueue = new Promise<Array<{ id: string; success: boolean; filename: string }>>(resolve => {
releaseEnqueue = resolve;
@@ -2590,7 +2637,7 @@ describe('useDownloadStore', () => {
const resume = useDownloadStore.getState().resumePendingDownloads();
await vi.waitFor(() => {
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(true);
expect(useDownloadStore.getState().allocationPendingIds.has('startup-torrent-allocation')).toBe(false);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_many',
expect.objectContaining({
+66 -40
View File
@@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -214,14 +214,26 @@ const advanceDownloadLifecycle = (id: string): bigint => {
const currentDownloadLifecycle = (id: string): bigint =>
downloadLifecycleGenerations.get(id) ?? 0n;
export const currentDownloadLifecycleGeneration = (id: string): string =>
currentDownloadLifecycle(id).toString();
type DispatchInvalidation = {
generation: bigint;
pendingDispatch?: Promise<boolean>;
};
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
const invalidateDispatch = async (
id: string,
resetRetainedProgress = false,
): Promise<DispatchInvalidation> => {
const generation = currentDownloadLifecycle(id);
const nextGeneration = advanceDownloadLifecycle(id);
// A new lifecycle cannot inherit the previous native allocation phase. The
// backend will emit a fresh marker for the new generation after admission.
useDownloadStore.getState().clearAllocationPending(id);
if (resetRetainedProgress) {
useDownloadProgressStore.getState().resetDownloadProgress(id);
}
try {
await invoke('cancel_enqueue_generation', { id, generation: generation.toString() });
} catch (error) {
@@ -230,8 +242,11 @@ const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> =>
return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) };
};
const invalidateAndWaitForDispatch = async (id: string): Promise<boolean> => {
const { pendingDispatch } = await invalidateDispatch(id);
const invalidateAndWaitForDispatch = async (
id: string,
resetRetainedProgress = false,
): Promise<boolean> => {
const { pendingDispatch } = await invalidateDispatch(id, resetRetainedProgress);
if (!pendingDispatch) return false;
await pendingDispatch;
return true;
@@ -434,18 +449,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
) {
return false;
}
const showsAllocationPhase = isAllocationPhaseEligible(admittedItem);
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, true);
}
let accepted;
try {
accepted = await invoke('enqueue_download', { item: enqueueItem });
} finally {
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, false);
}
}
const accepted = await invoke('enqueue_download', { item: enqueueItem });
backendAccepted = true;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
@@ -485,6 +489,12 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
const proxyBlocked = isSystemProxyConfigurationError(e);
const destinationAccessBlocked = isRetryableDestinationAccessError(e);
const message = errorMessage(e);
if (destinationAccessBlocked) {
useDownloadStore.getState().clearAllocationPending(id);
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(value => value !== id)
}));
}
useDownloadStore.getState().updateDownload(id, {
status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed',
hasBeenDispatched: false,
@@ -1045,7 +1055,8 @@ interface DownloadState {
allocationPendingIds: Set<string>;
registerBackendIds: (ids: string[]) => void;
unregisterBackendIds: (ids: string[]) => void;
setAllocationPending: (id: string, pending: boolean) => void;
setAllocationPending: (id: string, pending: boolean, lifecycleGeneration: string) => void;
clearAllocationPending: (id: string) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: (
@@ -1326,7 +1337,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// Fence any older enqueue before replacing a paused backend lifecycle.
// Otherwise a late addUri result can win the race and make this
// selection start outside the requested order.
const { pendingDispatch } = await invalidateDispatch(id);
const { pendingDispatch } = await invalidateDispatch(id, true);
if (pendingDispatch) await pendingDispatch;
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem || !canStartDownload(targetItem.status)) {
@@ -1425,7 +1436,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// A terminal aria2 gid is intentionally re-enqueued as a new
// lifecycle. Advance and cancel the old generation before dispatching
// so QueueManager does not reject the legitimate user retry as stale.
await invalidateAndWaitForDispatch(id);
await invalidateAndWaitForDispatch(id, true);
dispatchSucceeded = await dispatchItemInternal(id);
}
@@ -1662,12 +1673,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
for (const id of ids) nextSet.delete(id);
return { backendRegisteredIds: nextSet };
}),
setAllocationPending: (id, pending) => set((state) => {
setAllocationPending: (id, pending, lifecycleGeneration) => set((state) => {
// Native allocation events can arrive while persisted rows are still
// hydrating. Validate against the frontend lifecycle counter even when no
// row exists yet, then retain the marker until that row is projected.
if (lifecycleGeneration !== currentDownloadLifecycleGeneration(id)) return state;
const nextSet = new Set(state.allocationPendingIds);
if (pending) nextSet.add(id);
else nextSet.delete(id);
return { allocationPendingIds: nextSet };
}),
clearAllocationPending: (id) => set((state) => {
if (!state.allocationPendingIds.has(id)) return state;
const nextSet = new Set(state.allocationPendingIds);
nextSet.delete(id);
return { allocationPendingIds: nextSet };
}),
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
@@ -1852,6 +1873,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false
};
advanceDownloadLifecycle(item.id);
get().clearAllocationPending(item.id);
useDownloadProgressStore.getState().resetDownloadProgress(item.id);
set((state) => ({
downloads: reorderQueueWithPausedAtEnd([...state.downloads, ownedItem], queueId)
}));
@@ -1997,7 +2020,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
throw error;
}
useDownloadProgressStore.getState().clearDownloadProgress(id);
useDownloadProgressStore.getState().resetDownloadProgress(id);
info(`Download ${id} removed`);
syncSystemIntegrations();
},
@@ -2066,6 +2089,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false,
dateAdded: new Date().toISOString()
});
useDownloadProgressStore.getState().resetDownloadProgress(id);
await commitDownloadState();
@@ -2765,25 +2789,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
});
if (dispatchableItems.length === 0) return;
const allocationPendingIds = dispatchableItems
.filter(item => {
const current = latestItems.get(item.id);
return current !== undefined && isAllocationPhaseEligible(current);
})
.map(item => item.id);
allocationPendingIds.forEach(id => get().setAllocationPending(id, true));
let results;
try {
results = await invoke('enqueue_many', { items: dispatchableItems });
} finally {
allocationPendingIds.forEach(id => get().setAllocationPending(id, false));
}
const results = await invoke('enqueue_many', { items: dispatchableItems });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedErrors = new Map(
const failedResults = new Map(
results
.filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
.map(result => {
const message = result.error || 'Backend rejected the queued download.';
const destinationAccess = isRetryableDestinationAccessError(message);
return [result.id, {
message: destinationAccess
? destinationAccessErrorMessage(message)
: message,
status: destinationAccess ? 'ready' as const : 'failed' as const,
errorKind: destinationAccess
? ('destinationAccess' as DownloadErrorKind)
: undefined
}];
})
);
const acceptedFilenames = new Map(
results
@@ -2816,12 +2839,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
...state.backendRegisteredIds,
...liveAcceptedIds
]),
pendingOrder: state.pendingOrder.filter(id => !failedResults.has(id)),
downloads: state.downloads.map(download =>
failedErrors.has(download.id)
failedResults.has(download.id)
? {
...download,
status: 'failed' as const,
lastError: failedErrors.get(download.id)
status: failedResults.get(download.id)!.status,
hasBeenDispatched: false,
lastError: failedResults.get(download.id)!.message,
lastErrorKind: failedResults.get(download.id)!.errorKind
}
: liveAcceptedIds.has(download.id)
? {