From 0e50bdfe9e2e04e5e166e8d8bdafa9ea22e747b6 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 29 Jul 2026 15:49:53 +0330 Subject: [PATCH] fix(ui): ignore duplicate stale pause events during resume --- src/store/downloadStore.test.ts | 13 +++++++++++-- src/store/downloadStore.ts | 17 +++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index 00a40c9..66192c5 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -353,7 +353,7 @@ describe('useDownloadProgressStore', () => { release(); }); - it('ignores a stale paused event during resume and accepts the new active state', async () => { + it('ignores duplicate stale paused events during resume and accepts the new active state', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { handlers[event] = handler as (event: any) => void; @@ -378,6 +378,12 @@ describe('useDownloadProgressStore', () => { } }); expect(useDownloadStore.getState().downloads[0].status).toBe('queued'); + handlers['download-state']({ payload: { + id: 'resume-race', + status: 'paused' + } }); + expect(useDownloadStore.getState().downloads[0].status).toBe('queued'); + handlers['download-state']({ payload: { id: 'resume-race', status: 'downloading' @@ -386,7 +392,7 @@ describe('useDownloadProgressStore', () => { release(); }); - it('allows a later genuine pause after consuming the stale resume event', async () => { + it('accepts an explicit pause while a resume transition is in flight', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { handlers[event] = handler as (event: any) => void; @@ -411,6 +417,9 @@ describe('useDownloadProgressStore', () => { } }); expect(useDownloadStore.getState().downloads[0].status).toBe('queued'); + // pauseDownload replaces the resume intent before it asks the backend to + // pause, so the next paused event is authoritative. + setDownloadControlIntent('resume-pause-race', 'pause'); handlers['download-state']({ payload: { id: 'resume-pause-race', status: 'paused' diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 0f5b423..677c205 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -103,17 +103,18 @@ const startDownloadListeners = async () => { const status = payload.status as DownloadStatus; // resume_download queues the row before the backend can emit its new - // active state. A paused event already emitted by the old lifecycle may - // arrive in that gap. Do not let it overwrite the queued transition; - // otherwise the guard below would reject the legitimate downloading - // event and leave the row visibly paused forever. + // active state. Paused events already emitted by the old lifecycle may + // arrive in that gap, and more than one can be queued before the new + // lifecycle reports its state. Do not let any of them overwrite the + // queued transition; otherwise a duplicate stale event can leave the + // row visibly paused and make dispatch reject it before IPC. if (status === 'paused' && current.status === 'queued' && downloadControlIntentFor(payload.id) === 'resume') { - // Consume only the stale pause event that caused the resume race. - // A later real pause must be allowed through even if the backend has - // not emitted a new active state yet. - clearDownloadControlIntent(payload.id, 'resume'); + // Keep the resume intent until an active or terminal event proves + // that the new lifecycle has taken over. An explicit pause replaces + // this intent in pauseDownload, so a genuine user pause is still + // applied while the transition is in flight. return; } if (status === 'downloading' || status === 'processing' ||