fix(queue): harden resume failure and legacy controls

This commit is contained in:
NimBold
2026-07-29 16:14:44 +03:30
parent ce8a5499c3
commit 546be23c91
6 changed files with 75 additions and 5 deletions
+9
View File
@@ -420,6 +420,15 @@ impl DownloadStateEvent {
}
}
pub fn paused_with_error(id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
id: id.into(),
status: DownloadStatus::Paused.as_str().to_string(),
error: Some(error.into()),
file_name: None,
}
}
pub fn completed_with_file(id: impl Into<String>, file_name: impl Into<String>) -> Self {
Self {
id: id.into(),
+2 -2
View File
@@ -4701,9 +4701,9 @@ async fn resume_download(
);
let _ = app_handle_clone.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(
crate::ipc::DownloadStateEvent::paused_with_error(
&id_clone,
crate::ipc::DownloadStatus::Paused,
unpause_error,
),
);
return;
+34
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initDownloadListener, useDownloadProgressStore } from './downloadStore';
import {
clearDownloadControlIntents,
downloadControlIntentFor,
setDownloadControlIntent,
useDownloadStore
} from './useDownloadStore';
@@ -427,4 +428,37 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
release();
});
it('accepts an authoritative resume failure while ignoring stale paused events', 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: 'resume-failure',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: ''
}]
});
setDownloadControlIntent('resume-failure', 'resume');
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'resume-failure',
status: 'paused',
error: 'aria2 resume failed'
} });
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'paused',
lastError: 'aria2 resume failed'
});
expect(downloadControlIntentFor('resume-failure')).toBeUndefined();
release();
});
});
+3 -1
View File
@@ -110,7 +110,8 @@ const startDownloadListeners = async () => {
// row visibly paused and make dispatch reject it before IPC.
if (status === 'paused' &&
current.status === 'queued' &&
downloadControlIntentFor(payload.id) === 'resume') {
downloadControlIntentFor(payload.id) === 'resume' &&
!payload.error) {
// 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
@@ -123,6 +124,7 @@ const startDownloadListeners = async () => {
}
if (status === 'paused') {
clearDownloadControlIntent(payload.id, 'pause');
if (payload.error) clearDownloadControlIntent(payload.id, 'resume');
}
// Prevent stale lifecycle events from moving a paused row back into an
+22
View File
@@ -1836,6 +1836,28 @@ describe('useDownloadStore', () => {
expect(calls.some(call => call[0] === 'pause_download' && (call[1] as any).id === 'active')).toBe(true);
});
it('direct queue controls treat missing queue ids as the main queue', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'legacy-ready', url: 'http://ready', fileName: 'ready', status: 'ready', category: 'Other', dateAdded: '' },
{ id: 'legacy-active', url: 'http://active', fileName: 'active', status: 'processing', category: 'Other', dateAdded: '' },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['legacy-ready'];
return undefined;
});
await expect(useDownloadStore.getState().startQueue('00000000-0000-0000-0000-000000000001'))
.resolves.toEqual(['legacy-ready']);
await expect(useDownloadStore.getState().pauseQueue('00000000-0000-0000-0000-000000000001'))
.resolves.toBe(2);
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download'))
.toHaveLength(2);
});
it('resumes paused items that were never dispatched through the master start action', async () => {
useDownloadStore.setState({
downloads: [
+5 -2
View File
@@ -1609,7 +1609,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const operation = previousOperation.catch(() => []).then(async () => {
await waitForPendingStartupResume();
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.filter(item =>
(item.queueId || MAIN_QUEUE_ID) === queueId &&
(item.status === 'queued' || canStartDownload(item.status))
)
.sort(queuePositionComparator);
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
@@ -1736,7 +1739,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
advanceQueueControlGeneration(queueId);
const activeIds = get().downloads
.filter(item =>
item.queueId === queueId &&
(item.queueId || MAIN_QUEUE_ID) === queueId &&
(canPauseDownload(item.status) || backendDispatchPromises.has(item.id))
)
.map(item => item.id);