diff --git a/admin/app/utils/downloads.ts b/admin/app/utils/downloads.ts index 7c36378..6bc96a8 100644 --- a/admin/app/utils/downloads.ts +++ b/admin/app/utils/downloads.ts @@ -18,6 +18,7 @@ export async function doResumableDownload({ url, filepath, timeout = 30000, + stallTimeout = 60000, signal, onProgress, onComplete, @@ -88,11 +89,30 @@ export async function doResumableDownload({ let lastProgressTime = Date.now() let lastDownloadedBytes = startByte + // Stall detection: abort if no data received within stallTimeout ms + let lastReceivedBytes = startByte + let stallTimer = setTimeout(() => { + cleanup(new Error(`Download stalled: no data received for ${stallTimeout}ms from ${url}`)) + }, stallTimeout) + + const resetStallTimer = () => { + clearTimeout(stallTimer) + stallTimer = setTimeout(() => { + cleanup(new Error(`Download stalled: no data received for ${stallTimeout}ms from ${url}`)) + }, stallTimeout) + } + // Progress tracking stream to monitor data flow const progressStream = new Transform({ transform(chunk: Buffer, _: any, callback: Function) { downloadedBytes += chunk.length + // Reset stall timer on every chunk received + if (downloadedBytes > lastReceivedBytes) { + lastReceivedBytes = downloadedBytes + resetStallTimer() + } + // Update progress tracking const now = Date.now() if (onProgress && now - lastProgressTime >= 500) { @@ -118,6 +138,7 @@ export async function doResumableDownload({ // Handle errors and cleanup const cleanup = (error?: Error) => { + clearTimeout(stallTimer) progressStream.destroy() response.data.destroy() writeStream.destroy() @@ -136,6 +157,7 @@ export async function doResumableDownload({ }) writeStream.on('finish', async () => { + clearTimeout(stallTimer) if (onProgress) { onProgress({ downloadedBytes, @@ -191,7 +213,10 @@ export async function doResumableDownloadWithRetry({ const isAborted = error.name === 'AbortError' || error.code === 'ABORT_ERR' const isNetworkError = - error.code === 'ECONNRESET' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT' + error.code === 'ECONNRESET' || + error.code === 'ENOTFOUND' || + error.code === 'ETIMEDOUT' || + (error.message as string)?.includes('stalled') onAttemptError?.(error, attempt) if (isAborted) { diff --git a/admin/types/downloads.ts b/admin/types/downloads.ts index 11734f6..9652186 100644 --- a/admin/types/downloads.ts +++ b/admin/types/downloads.ts @@ -7,6 +7,8 @@ export type DoResumableDownloadParams = { onProgress?: (progress: DoResumableDownloadProgress) => void onComplete?: (url: string, path: string) => void | Promise forceNew?: boolean + /** How long (ms) to wait without receiving data before aborting. Defaults to 60000 (60s). */ + stallTimeout?: number } export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {