fix(downloads): surface allocation and retain terminal progress

- emit native allocation state around Aria2 preallocation
- preflight batch destinations before backend admission
- preserve exact progress across retries and terminal states
- fence allocation and progress across pauses, retries, and stale GIDs
This commit is contained in:
NimBold
2026-08-21 01:33:13 +03:30
parent c355e99913
commit 2d265ce7c8
12 changed files with 1652 additions and 166 deletions
+66 -40
View File
@@ -10,7 +10,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isAllocationPhaseEligible, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -214,14 +214,26 @@ const advanceDownloadLifecycle = (id: string): bigint => {
const currentDownloadLifecycle = (id: string): bigint =>
downloadLifecycleGenerations.get(id) ?? 0n;
export const currentDownloadLifecycleGeneration = (id: string): string =>
currentDownloadLifecycle(id).toString();
type DispatchInvalidation = {
generation: bigint;
pendingDispatch?: Promise<boolean>;
};
const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> => {
const invalidateDispatch = async (
id: string,
resetRetainedProgress = false,
): Promise<DispatchInvalidation> => {
const generation = currentDownloadLifecycle(id);
const nextGeneration = advanceDownloadLifecycle(id);
// A new lifecycle cannot inherit the previous native allocation phase. The
// backend will emit a fresh marker for the new generation after admission.
useDownloadStore.getState().clearAllocationPending(id);
if (resetRetainedProgress) {
useDownloadProgressStore.getState().resetDownloadProgress(id);
}
try {
await invoke('cancel_enqueue_generation', { id, generation: generation.toString() });
} catch (error) {
@@ -230,8 +242,11 @@ const invalidateDispatch = async (id: string): Promise<DispatchInvalidation> =>
return { generation: nextGeneration, pendingDispatch: backendDispatchPromises.get(id) };
};
const invalidateAndWaitForDispatch = async (id: string): Promise<boolean> => {
const { pendingDispatch } = await invalidateDispatch(id);
const invalidateAndWaitForDispatch = async (
id: string,
resetRetainedProgress = false,
): Promise<boolean> => {
const { pendingDispatch } = await invalidateDispatch(id, resetRetainedProgress);
if (!pendingDispatch) return false;
await pendingDispatch;
return true;
@@ -434,18 +449,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
) {
return false;
}
const showsAllocationPhase = isAllocationPhaseEligible(admittedItem);
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, true);
}
let accepted;
try {
accepted = await invoke('enqueue_download', { item: enqueueItem });
} finally {
if (showsAllocationPhase) {
useDownloadStore.getState().setAllocationPending(id, false);
}
}
const accepted = await invoke('enqueue_download', { item: enqueueItem });
backendAccepted = true;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
await removeStaleBackendDispatch(id);
@@ -485,6 +489,12 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
const proxyBlocked = isSystemProxyConfigurationError(e);
const destinationAccessBlocked = isRetryableDestinationAccessError(e);
const message = errorMessage(e);
if (destinationAccessBlocked) {
useDownloadStore.getState().clearAllocationPending(id);
useDownloadStore.setState(state => ({
pendingOrder: state.pendingOrder.filter(value => value !== id)
}));
}
useDownloadStore.getState().updateDownload(id, {
status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed',
hasBeenDispatched: false,
@@ -1045,7 +1055,8 @@ interface DownloadState {
allocationPendingIds: Set<string>;
registerBackendIds: (ids: string[]) => void;
unregisterBackendIds: (ids: string[]) => void;
setAllocationPending: (id: string, pending: boolean) => void;
setAllocationPending: (id: string, pending: boolean, lifecycleGeneration: string) => void;
clearAllocationPending: (id: string) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (ids: string | string[], direction: 'up' | 'down') => Promise<void>;
moveManyInQueueToPosition: (
@@ -1326,7 +1337,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// Fence any older enqueue before replacing a paused backend lifecycle.
// Otherwise a late addUri result can win the race and make this
// selection start outside the requested order.
const { pendingDispatch } = await invalidateDispatch(id);
const { pendingDispatch } = await invalidateDispatch(id, true);
if (pendingDispatch) await pendingDispatch;
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem || !canStartDownload(targetItem.status)) {
@@ -1425,7 +1436,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// 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);
await invalidateAndWaitForDispatch(id, true);
dispatchSucceeded = await dispatchItemInternal(id);
}
@@ -1662,12 +1673,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
for (const id of ids) nextSet.delete(id);
return { backendRegisteredIds: nextSet };
}),
setAllocationPending: (id, pending) => set((state) => {
setAllocationPending: (id, pending, lifecycleGeneration) => set((state) => {
// Native allocation events can arrive while persisted rows are still
// hydrating. Validate against the frontend lifecycle counter even when no
// row exists yet, then retain the marker until that row is projected.
if (lifecycleGeneration !== currentDownloadLifecycleGeneration(id)) return state;
const nextSet = new Set(state.allocationPendingIds);
if (pending) nextSet.add(id);
else nextSet.delete(id);
return { allocationPendingIds: nextSet };
}),
clearAllocationPending: (id) => set((state) => {
if (!state.allocationPendingIds.has(id)) return state;
const nextSet = new Set(state.allocationPendingIds);
nextSet.delete(id);
return { allocationPendingIds: nextSet };
}),
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
@@ -1852,6 +1873,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false
};
advanceDownloadLifecycle(item.id);
get().clearAllocationPending(item.id);
useDownloadProgressStore.getState().resetDownloadProgress(item.id);
set((state) => ({
downloads: reorderQueueWithPausedAtEnd([...state.downloads, ownedItem], queueId)
}));
@@ -1997,7 +2020,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
throw error;
}
useDownloadProgressStore.getState().clearDownloadProgress(id);
useDownloadProgressStore.getState().resetDownloadProgress(id);
info(`Download ${id} removed`);
syncSystemIntegrations();
},
@@ -2066,6 +2089,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
hasBeenDispatched: false,
dateAdded: new Date().toISOString()
});
useDownloadProgressStore.getState().resetDownloadProgress(id);
await commitDownloadState();
@@ -2765,25 +2789,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
});
if (dispatchableItems.length === 0) return;
const allocationPendingIds = dispatchableItems
.filter(item => {
const current = latestItems.get(item.id);
return current !== undefined && isAllocationPhaseEligible(current);
})
.map(item => item.id);
allocationPendingIds.forEach(id => get().setAllocationPending(id, true));
let results;
try {
results = await invoke('enqueue_many', { items: dispatchableItems });
} finally {
allocationPendingIds.forEach(id => get().setAllocationPending(id, false));
}
const results = await invoke('enqueue_many', { items: dispatchableItems });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedErrors = new Map(
const failedResults = new Map(
results
.filter(result => !result.success)
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
.map(result => {
const message = result.error || 'Backend rejected the queued download.';
const destinationAccess = isRetryableDestinationAccessError(message);
return [result.id, {
message: destinationAccess
? destinationAccessErrorMessage(message)
: message,
status: destinationAccess ? 'ready' as const : 'failed' as const,
errorKind: destinationAccess
? ('destinationAccess' as DownloadErrorKind)
: undefined
}];
})
);
const acceptedFilenames = new Map(
results
@@ -2816,12 +2839,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
...state.backendRegisteredIds,
...liveAcceptedIds
]),
pendingOrder: state.pendingOrder.filter(id => !failedResults.has(id)),
downloads: state.downloads.map(download =>
failedErrors.has(download.id)
failedResults.has(download.id)
? {
...download,
status: 'failed' as const,
lastError: failedErrors.get(download.id)
status: failedResults.get(download.id)!.status,
hasBeenDispatched: false,
lastError: failedResults.get(download.id)!.message,
lastErrorKind: failedResults.get(download.id)!.errorKind
}
: liveAcceptedIds.has(download.id)
? {