mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +00:00
fix(downloads): harden aria2 recovery and resume lifecycle
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
|
||||
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
@@ -162,8 +161,7 @@ export default function LogsView() {
|
||||
filters: [{ name: 'Log Files', extensions: ['log'] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const logsContent = await invoke('export_logs', {});
|
||||
await writeTextFile(path, logsContent);
|
||||
await invoke('export_logs', { destination: path });
|
||||
addToast({ message: 'Support logs exported', variant: 'success' });
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
const formatLastTry = (value?: string): string => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? '-'
|
||||
: date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||
@@ -204,7 +212,7 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ type CommandMap = {
|
||||
args: { baseFolder: string; subfolders: Record<string, string> };
|
||||
result: void;
|
||||
};
|
||||
export_logs: { args: Record<string, never>; result: string };
|
||||
export_logs: { args: { destination?: string }; result: string };
|
||||
read_logs: { args: { limit: number }; result: string[] };
|
||||
clear_logs: { args: undefined; result: void };
|
||||
toggle_log_pause: { args: { pause: boolean }; result: void };
|
||||
|
||||
@@ -89,7 +89,10 @@ const startDownloadListeners = async () => {
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {})
|
||||
...(payload.error ? { lastError: payload.error } : {}),
|
||||
...((status === 'downloading' || status === 'retrying')
|
||||
? { lastTry: new Date().toISOString() }
|
||||
: {})
|
||||
};
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
|
||||
@@ -347,26 +347,79 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
|
||||
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
|
||||
let enqueueGeneration: string | undefined;
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
{ id: 'resume-generation', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['1']),
|
||||
backendRegisteredIds: new Set(['resume-generation']),
|
||||
});
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string, args?: unknown) => {
|
||||
if (cmd === 'resume_download') return false; // Not resumable
|
||||
if (cmd === 'get_pending_order') return ['1'];
|
||||
if (cmd === 'enqueue_download') {
|
||||
enqueueGeneration = (args as { item: { lifecycle_generation: string } }).item.lifecycle_generation;
|
||||
return { id: 'resume-generation', filename: 'f1' };
|
||||
}
|
||||
if (cmd === 'get_pending_order') return ['resume-generation'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().resumeDownload('1');
|
||||
await useDownloadStore.getState().resumeDownload('resume-generation');
|
||||
|
||||
// It should have called resume_download, then unregistered, then enqueue_download
|
||||
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
|
||||
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
|
||||
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
|
||||
expect(enqueueGeneration).toBe('1');
|
||||
expect(useDownloadStore.getState().downloads[0].lastTry).toEqual(expect.any(String));
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('resume-generation')).toBe(true); // Re-registered by dispatchItem
|
||||
});
|
||||
|
||||
it('does not re-enqueue when the existing resume RPC fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'resume-rpc-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
|
||||
] as any[],
|
||||
backendRegisteredIds: new Set(['resume-rpc-error']),
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'resume_download') throw new Error('aria2 RPC unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('resume-rpc-error')).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'enqueue_download')
|
||||
).toBe(false);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
lastTry: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans an accepted backend enqueue when queue reconciliation fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'enqueue-reconcile-error', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
|
||||
] as any[],
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'enqueue_download') return { id: 'enqueue-reconcile-error', filename: 'f1' };
|
||||
if (cmd === 'get_pending_order') throw new Error('queue state unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().startQueue('MAIN')).resolves.toEqual([]);
|
||||
|
||||
expect(
|
||||
vi.mocked(ipc.invokeCommand).mock.calls.some(([command]) => command === 'remove_download')
|
||||
).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
lastError: 'queue state unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
|
||||
const promise = (async () => {
|
||||
let lifecycleGeneration: bigint | null = null;
|
||||
let backendAccepted = false;
|
||||
try {
|
||||
const state = useDownloadStore.getState();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
@@ -139,7 +140,11 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
lastTry: new Date().toISOString()
|
||||
});
|
||||
const accepted = await invoke('enqueue_download', { item: enqueueItem });
|
||||
backendAccepted = true;
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
await removeStaleBackendDispatch(id);
|
||||
return false;
|
||||
@@ -164,6 +169,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to dispatch ${id}:`, e);
|
||||
if (backendAccepted && lifecycleGeneration !== null) {
|
||||
await removeStaleBackendDispatch(id);
|
||||
}
|
||||
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'failed',
|
||||
@@ -783,14 +791,19 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
status: 'queued',
|
||||
speed: '-',
|
||||
eta: '-',
|
||||
queuePosition: maxPos + 1
|
||||
queuePosition: maxPos + 1,
|
||||
lastTry: new Date().toISOString()
|
||||
});
|
||||
|
||||
const resumedExisting = await invoke('resume_download', { id }).catch(() => false);
|
||||
const resumedExisting = await invoke('resume_download', { id });
|
||||
|
||||
let dispatchSucceeded = resumedExisting;
|
||||
if (!dispatchSucceeded) {
|
||||
get().unregisterBackendIds([id]);
|
||||
// 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);
|
||||
dispatchSucceeded = await dispatchItem(id);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user