From 8e02a61c3f9667edefbecd3c0fbe383a3fcc1a26 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 10:23:06 +0330 Subject: [PATCH] fix(downloads): preserve terminal startup state --- src/store/useDownloadStore.test.ts | 34 +++++++++++++++++++++ src/store/useDownloadStore.ts | 49 +++++++++++++++++++----------- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 0c7c97c..fad430e 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -627,6 +627,40 @@ describe('useDownloadStore', () => { ).toHaveLength(0); }); + it('does not restore a registration after a fast startup terminal event', 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-completed', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + status: 'queued', + category: 'Other', + dateAdded: '', + queueId: '00000000-0000-0000-0000-000000000001', + hasBeenDispatched: true + })]; + } + if (cmd === 'enqueue_many') { + useDownloadStore.setState(state => ({ + backendRegisteredIds: new Set(), + downloads: state.downloads.map(download => download.id === 'startup-completed' + ? { ...download, status: 'completed' as const } + : download) + })); + return [{ id: 'startup-completed', success: true, filename: 'file.bin' }]; + } + if (cmd === 'get_pending_order') return []; + return undefined; + }); + + await useDownloadStore.getState().initDB(); + + expect(useDownloadStore.getState().downloads[0].status).toBe('completed'); + expect(useDownloadStore.getState().backendRegisteredIds.has('startup-completed')).toBe(false); + }); + it('redownloads fallback media without requiring a format selector', async () => { useDownloadStore.setState({ downloads: [{ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 4bc3d9b..f58688f 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1176,28 +1176,43 @@ export const useDownloadStore = create((set, get) => ({ .filter(result => !result.success) .map(result => [result.id, result.error || 'Backend rejected the queued download.']) ); + const acceptedIdSet = new Set(registeredIds); // Commit backend ownership as soon as enqueue_many accepts an item. // The order query is a separate best-effort view read; if it fails, // forgetting these registrations would let a later queue start // enqueue the same backend lifecycle a second time. - set(state => ({ - backendRegisteredIds: new Set([ - ...state.backendRegisteredIds, - ...registeredIds - ]), - downloads: state.downloads.map(download => - failedErrors.has(download.id) - ? { - ...download, - status: 'failed' as const, - lastError: failedErrors.get(download.id) - } - : registeredIds.includes(download.id) - ? { ...download, hasBeenDispatched: true, lastError: undefined } - : download - ) - })); + set(state => { + // A very fast backend transfer can emit a terminal event before + // this batch result is merged. Preserve that event's ownership + // cleanup instead of re-registering an already-terminal ID. + const liveAcceptedIds = new Set( + state.downloads + .filter(download => + acceptedIdSet.has(download.id) && + download.status !== 'completed' && + download.status !== 'failed' + ) + .map(download => download.id) + ); + return { + backendRegisteredIds: new Set([ + ...state.backendRegisteredIds, + ...liveAcceptedIds + ]), + downloads: state.downloads.map(download => + failedErrors.has(download.id) + ? { + ...download, + status: 'failed' as const, + lastError: failedErrors.get(download.id) + } + : liveAcceptedIds.has(download.id) + ? { ...download, hasBeenDispatched: true, lastError: undefined } + : download + ) + }; + }); try { const order = await invoke('get_pending_order', { queueId: null });