fix(torrents): harden removal reservation recovery

This commit is contained in:
NimBold
2026-08-03 03:56:15 +03:30
parent 32034e90b3
commit 79c0e48c43
11 changed files with 508 additions and 216 deletions
+2
View File
@@ -57,6 +57,8 @@ type CommandMap = {
remove_download: { args: { id: string; deleteAssets: boolean; preserveResumable?: boolean }; result: void };
get_download_primary_path: { args: { id: string }; result: string | null };
detach_download_for_reconfigure: { args: { id: string }; result: void };
clear_torrent_removal_paths: { args: { id: string }; result: void };
reconcile_torrent_removal_reservations: { args: undefined; result: number };
begin_dock_badge_session: { args: undefined; result: number };
update_dock_badge: { args: { count: number; generation: number; session: number }; result: void };
get_platform_info: { args: undefined; result: PlatformInfo };
+32
View File
@@ -135,6 +135,38 @@ describe('useDownloadStore', () => {
expect(fileName.endsWith('.mp4')).toBe(true);
});
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
useDownloadStore.setState({
downloads: [{
id: 'paused-torrent-removal',
url: 'magnet:?xt=urn:btih:abc',
fileName: 'torrent',
status: 'paused',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentFileIndices: [0],
torrentRemoveUnselectedFile: true
}] as any[],
backendRegisteredIds: new Set(['paused-torrent-removal'])
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().applyProperties('paused-torrent-removal', {
torrentRemoveUnselectedFile: false
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'paused-torrent-removal' }
);
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'clear_torrent_removal_paths',
{ id: 'paused-torrent-removal' }
);
expect(useDownloadStore.getState().downloads[0].torrentRemoveUnselectedFile).toBe(false);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true
+22
View File
@@ -913,12 +913,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const normalizedUpdates = updates.fileName === undefined
? updates
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
const disablingTorrentRemoval = item.isTorrent === true
&& normalizedUpdates.torrentRemoveUnselectedFile === false
&& item.torrentRemoveUnselectedFile !== false;
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
if (disablingTorrentRemoval) {
await invoke('clear_torrent_removal_paths', { id });
}
state.updateDownload(id, normalizedUpdates);
return;
}
@@ -932,6 +938,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
state.unregisterBackendIds([id]);
set(current => ({ pendingOrder: current.pendingOrder.filter(value => value !== id) }));
}
if (disablingTorrentRemoval) {
await invoke('clear_torrent_removal_paths', { id });
}
state.updateDownload(id, normalizedUpdates);
if (isRegistered || wasDispatching) {
const dispatched = await dispatchItemInternal(id);
@@ -951,6 +960,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
state.unregisterBackendIds([id]);
}
if (disablingTorrentRemoval) {
await invoke('clear_torrent_removal_paths', { id });
}
state.updateDownload(id, normalizedUpdates);
}
};
@@ -2386,6 +2398,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
: state.downloads
}));
// A process can die after Aria2 has removed the unselected files but
// before the terminal event clears Firelink's reservation. Reclaim
// only the conservative terminal cases in the backend before queued
// downloads are allowed to claim paths on startup.
try {
await invoke('reconcile_torrent_removal_reservations');
} catch (error) {
console.warn('Could not reconcile Torrent removal reservations during startup:', error);
}
// The backend dispatcher is live before the frontend finishes startup.
// Synchronize the normalized queue policy before any saved download is
// allowed to claim a permit.