diff --git a/apps/electron-backend/src/app/events/database/download-catchup-journal.ts b/apps/electron-backend/src/app/events/database/download-catchup-journal.ts index f99452d3c..18b276e08 100644 --- a/apps/electron-backend/src/app/events/database/download-catchup-journal.ts +++ b/apps/electron-backend/src/app/events/database/download-catchup-journal.ts @@ -1,4 +1,4 @@ -import { inArray } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { lstatSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import * as schema from '../../database/schema'; @@ -38,6 +38,16 @@ export async function recordArchiveFinalization( }); } +/** An explicitly restarted transfer must never inherit an earlier attempt's proof. */ +export async function clearArchiveFinalization( + db: DownloadsDatabase, + downloadId: number +): Promise { + await db + .delete(schema.downloadArchiveFinalizations) + .where(eq(schema.downloadArchiveFinalizations.downloadId, downloadId)); +} + function identity(value: unknown): value is ArchiveFileIdentity { if (!value || typeof value !== 'object') return false; const candidate = value as ArchiveFileIdentity; diff --git a/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts b/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts index 679718a57..3a3e0d3c5 100644 --- a/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-catchup-transfer.spec.ts @@ -1,3 +1,4 @@ +import { clearArchiveFinalization } from './download-catchup-journal'; import { mkdtemp, readFile, @@ -26,6 +27,10 @@ import { } from './download-catchup-limits'; import { stat } from 'node:fs/promises'; +jest.mock('./download-catchup-journal', () => ({ + ...jest.requireActual('./download-catchup-journal'), + clearArchiveFinalization: jest.fn().mockResolvedValue(undefined), +})); jest.mock('./download-catchup-limits', () => { const actual = jest.requireActual('./download-catchup-limits'); return { @@ -118,6 +123,14 @@ describe('TS archive transfer', () => { }; try { await writeFile(path + '.part', 'old data'); + jest.mocked(clearArchiveFinalization).mockImplementationOnce( + async (_db, id) => { + expect(id).toBe(task.id); + expect(await readFile(path + '.part', 'utf8')).toBe( + 'old data' + ); + } + ); const actualLimit = jest.requireActual< typeof import('./download-catchup-limits') >('./download-catchup-limits').getArchiveByteLimit; @@ -167,6 +180,10 @@ describe('TS archive transfer', () => { expect.anything() ); expect(task.resumeValidator).toBeNull(); + expect(clearArchiveFinalization).toHaveBeenCalledWith( + expect.anything(), + task.id + ); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts b/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts index f813e37b8..24430765f 100644 --- a/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts +++ b/apps/electron-backend/src/app/events/database/download-catchup-transfer.ts @@ -1,3 +1,4 @@ +import { clearArchiveFinalization } from './download-catchup-journal'; import { assertArchiveCopyHeadroom, createArchiveByteGuard, @@ -70,6 +71,7 @@ export async function transferCatchupToPartialFile( let output: Awaited> | undefined; let pendingProgress = Promise.resolve(); try { + await clearArchiveFinalization(db, task.id); const response = await requestWithValidatedRedirects( task.url, { diff --git a/apps/electron-backend/src/app/events/database/download-file-finalize.ts b/apps/electron-backend/src/app/events/database/download-file-finalize.ts index 7382b3993..e62db042c 100644 --- a/apps/electron-backend/src/app/events/database/download-file-finalize.ts +++ b/apps/electron-backend/src/app/events/database/download-file-finalize.ts @@ -1,3 +1,4 @@ +import { removePartialDownloadFile } from './download-file-path'; import { constants } from 'node:fs'; import { copyFile, link, stat, unlink } from 'node:fs/promises'; import type { ReservedPartialDownloadFile } from './download-file-path'; @@ -61,3 +62,16 @@ function canCopyCompletedPartialAfterLinkFailure(error: unknown): boolean { errorCode === 'EXDEV' ); } + +/** @returns false when a .part exists but could not be deleted. */ +export function removePartialFile( + filePath: string | null | undefined +): boolean { + try { + removePartialDownloadFile(filePath); + return true; + } catch (error) { + console.error('[Downloads] Failed to delete partial file:', error); + return false; + } +} diff --git a/apps/electron-backend/src/app/events/database/download-finalize.ts b/apps/electron-backend/src/app/events/database/download-finalize.ts index ec915ff82..29750f98c 100644 --- a/apps/electron-backend/src/app/events/database/download-finalize.ts +++ b/apps/electron-backend/src/app/events/database/download-finalize.ts @@ -1,5 +1,8 @@ import { recordArchiveFinalization } from './download-catchup-journal'; -import { finalizePartialDownload } from './download-file-finalize'; +import { + finalizePartialDownload, + removePartialFile, +} from './download-file-finalize'; import { cleanupCatchupPartial } from './download-catchup-cleanup'; import { finalizeCatchupPartial, @@ -12,7 +15,6 @@ import * as schema from '../../database/schema'; import { getPartialDownloadPath, getPartialDownloadSize, - removePartialDownloadFile, type ReservedPartialDownloadFile, } from './download-file-path'; import type { @@ -343,15 +345,4 @@ export function getPausedByteCount(task: DownloadTask): number { } } -/** @returns false when a .part exists but could not be deleted. */ -export function removePartialFile( - filePath: string | null | undefined -): boolean { - try { - removePartialDownloadFile(filePath); - return true; - } catch (error) { - console.error('[Downloads] Failed to delete partial file:', error); - return false; - } -} +export { removePartialFile } from './download-file-finalize'; diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 91e67e027..2e61fcad4 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -64,7 +64,8 @@ requires that proof and a matching regular file, identity and size to recover an archive; termination before promotion leaves the verified partial paused. An owned incomplete copy is removed by journal identity before the source resumes. The journal also makes startup partial cleanup identity-aware and remains with -completed archives until they are removed. Process-local proof allows immediate +completed archives until they are removed. An explicitly restarted transfer +clears the previous attempt's proof before network requests or partial-file writes. Process-local proof allows immediate recovery after a transient completion DB error without waiting for a restart. An explicit cancellation of a queued/paused archive captures the selected regular partial using the same cleanup helper; symlink entries are preserved.