fix(ui): ignore duplicate stale pause events during resume

This commit is contained in:
NimBold
2026-07-29 15:49:53 +03:30
parent f4ef1a4b28
commit 0e50bdfe9e
2 changed files with 20 additions and 10 deletions
+11 -2
View File
@@ -353,7 +353,7 @@ describe('useDownloadProgressStore', () => {
release(); 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<string, (event: any) => void> = {}; const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void; handlers[event] = handler as (event: any) => void;
@@ -378,6 +378,12 @@ describe('useDownloadProgressStore', () => {
} }); } });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued'); 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: { handlers['download-state']({ payload: {
id: 'resume-race', id: 'resume-race',
status: 'downloading' status: 'downloading'
@@ -386,7 +392,7 @@ describe('useDownloadProgressStore', () => {
release(); 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<string, (event: any) => void> = {}; const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void; handlers[event] = handler as (event: any) => void;
@@ -411,6 +417,9 @@ describe('useDownloadProgressStore', () => {
} }); } });
expect(useDownloadStore.getState().downloads[0].status).toBe('queued'); 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: { handlers['download-state']({ payload: {
id: 'resume-pause-race', id: 'resume-pause-race',
status: 'paused' status: 'paused'
+9 -8
View File
@@ -103,17 +103,18 @@ const startDownloadListeners = async () => {
const status = payload.status as DownloadStatus; const status = payload.status as DownloadStatus;
// resume_download queues the row before the backend can emit its new // resume_download queues the row before the backend can emit its new
// active state. A paused event already emitted by the old lifecycle may // active state. Paused events already emitted by the old lifecycle may
// arrive in that gap. Do not let it overwrite the queued transition; // arrive in that gap, and more than one can be queued before the new
// otherwise the guard below would reject the legitimate downloading // lifecycle reports its state. Do not let any of them overwrite the
// event and leave the row visibly paused forever. // queued transition; otherwise a duplicate stale event can leave the
// row visibly paused and make dispatch reject it before IPC.
if (status === 'paused' && if (status === 'paused' &&
current.status === 'queued' && current.status === 'queued' &&
downloadControlIntentFor(payload.id) === 'resume') { downloadControlIntentFor(payload.id) === 'resume') {
// Consume only the stale pause event that caused the resume race. // Keep the resume intent until an active or terminal event proves
// A later real pause must be allowed through even if the backend has // that the new lifecycle has taken over. An explicit pause replaces
// not emitted a new active state yet. // this intent in pauseDownload, so a genuine user pause is still
clearDownloadControlIntent(payload.id, 'resume'); // applied while the transition is in flight.
return; return;
} }
if (status === 'downloading' || status === 'processing' || if (status === 'downloading' || status === 'processing' ||