From 498352de3dd72b7fe6f17d4a5f0c465a2f13e989 Mon Sep 17 00:00:00 2001 From: Aaron Bird Date: Sat, 21 Mar 2026 15:10:15 -0400 Subject: [PATCH] fix(downloads): stage downloads to .tmp to prevent Kiwix loading partial files Downloads are now written to `filepath + '.tmp'` and atomically renamed to the final path only on successful completion. Kiwix globs for `*.zim` and ZimService filters `.endsWith('.zim')`, so `.tmp` files are invisible to both during download. The same staging applies to `.pmtiles` map files. Ref #372 Co-Authored-By: Claude Sonnet 4.6 --- admin/app/utils/downloads.ts | 37 ++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/admin/app/utils/downloads.ts b/admin/app/utils/downloads.ts index 1c26a74..b439be8 100644 --- a/admin/app/utils/downloads.ts +++ b/admin/app/utils/downloads.ts @@ -6,6 +6,7 @@ import axios from 'axios' import { Transform } from 'stream' import { deleteFileIfExists, ensureDirectoryExists, getFileStatsIfExists } from './fs.js' import { createWriteStream } from 'fs' +import { rename } from 'fs/promises' import path from 'path' /** @@ -27,13 +28,16 @@ export async function doResumableDownload({ const dirname = path.dirname(filepath) await ensureDirectoryExists(dirname) - // Check if partial file exists for resume + // Stage download to a .tmp file so consumers (e.g. Kiwix) never see a partial file + const tempPath = filepath + '.tmp' + + // Check if partial .tmp file exists for resume let startByte = 0 let appendMode = false - const existingStats = await getFileStatsIfExists(filepath) + const existingStats = await getFileStatsIfExists(tempPath) if (existingStats && !forceNew) { - startByte = existingStats.size + startByte = Number(existingStats.size) appendMode = true } @@ -55,14 +59,24 @@ export async function doResumableDownload({ } } - // If file is already complete and not forcing overwrite just return filepath - if (startByte === totalBytes && totalBytes > 0 && !forceNew) { + // If final file already exists at correct size, return early (idempotent) + const finalFileStats = await getFileStatsIfExists(filepath) + if (finalFileStats && Number(finalFileStats.size) === totalBytes && totalBytes > 0 && !forceNew) { return filepath } - // If server doesn't support range requests and we have a partial file, delete it + // If .tmp file is already at correct size (complete but never renamed), just rename it + if (startByte === totalBytes && totalBytes > 0 && !forceNew) { + await rename(tempPath, filepath) + if (onComplete) { + await onComplete(url, filepath) + } + return filepath + } + + // If server doesn't support range requests and we have a partial .tmp file, delete it if (!supportsRangeRequests && startByte > 0) { - await deleteFileIfExists(filepath) + await deleteFileIfExists(tempPath) startByte = 0 appendMode = false } @@ -131,7 +145,7 @@ export async function doResumableDownload({ }, }) - const writeStream = createWriteStream(filepath, { + const writeStream = createWriteStream(tempPath, { flags: appendMode ? 'a' : 'w', }) @@ -157,6 +171,13 @@ export async function doResumableDownload({ writeStream.on('finish', async () => { clearStallTimer() + try { + // Atomically move the completed .tmp file to the final path + await rename(tempPath, filepath) + } catch (renameError) { + reject(renameError) + return + } if (onProgress) { onProgress({ downloadedBytes,