From 191e2b8a8323051352db6edd0a5cb541a371b0ff Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 15 Mar 2026 18:00:55 -0400 Subject: [PATCH] fix: add stall detection to resumable downloader Axios stream timeout only covers initial connection, not the body. Once streaming starts, a stalled server would hang indefinitely. - Added stallTimeout param (default 60s) to DoResumableDownloadParams - Reset a timer on every received chunk; abort if no data arrives within the window - Treat stall errors as retriable network errors in doResumableDownloadWithRetry so partial downloads can resume from where they left off --- admin/app/utils/downloads.ts | 27 ++++++++++++++++++++++++++- admin/types/downloads.ts | 2 ++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 & {