Skip to content

Conversation

@ilux251
Copy link

@ilux251 ilux251 commented Jun 24, 2025

Support automatic resumption of interrupted downloads

Summary

This pull request improves the handling of the interrupted download state in electron-dl. Currently, when a download is interrupted and the 'updated' event is emitted, there is no automatic attempt to resume the download.

This PR includes:

  • Automatic resumption of interrupted downloads if item.canResume() returns true.
  • A configurable retry limit using the retryLimit parameter (default: 60 attempts).
  • Abort handling if the retry limit is reached without a successful resume.

Motivation

According to the official Electron documentation, interrupted downloads can typically be resumed within the 'updated' event by using item.resume().

There was a previous attempt to address this in PR #71, but it was not merged at the time.

Without this improvement, interrupted downloads may fail permanently (only on Windows) even if they are resumable. This PR improves download stability by automatically retrying interrupted downloads that can be resumed, helping to recover from temporary network problems.

Example Usage

download(view, "https://google.com", {
  retryLimit: 60 // Optional, default is 60
});

@sindresorhus
Copy link
Owner

I don't think toleranceBytes should be exposed as an option.

@sindresorhus
Copy link
Owner

AI review:


Blockers

  • Type hole + runtime crash
    options can be undefined. You access options.retryLimit/options.toleranceBytes without guarding. Fix with safe destructuring. (GitHub)
  • Undocumented/untype’d option
    You read options.toleranceBytes but don’t add it to index.d.ts or the README. Either drop it or document + type it. (GitHub)
  • UX regression on failure
    When retries are exhausted you callback(new Error(...)) and item.cancel(). That flips the terminal state to cancelled, which likely suppresses the built-in “interrupted” error UX in the registered handler path (README documents that UX) and instead fires onCancel. Preserve the existing “interrupted” error behavior. (Electron)
  • Double-callback risk
    You invoke callback(Error) in 'updated', then cancel() triggers 'done'cancelled. Unless guarded, the 'done' path can also call the callback. Add a once/flag guard.
  • Retry logic is over-complicated and leaky
    Using a per-event setTimeout can schedule many resumes in bursts. Use a single timer or a backoff clock. Also, resetting the counter only after 50 KB progress is arbitrary and can falsely trip the limit on slow links.

API/Docs mismatches

  • README still doesn’t list retryLimit (or your tolerance option). Add both, including defaults. The README currently documents errorMessage (used in your new path) and the auto-handled “interrupted” error UX.
  • Keep types/docs aligned across “window can be BrowserWindow | WebContentsView”. README already says that; ensure index.d.ts matches.

Implementation fixes (minimal)

diff --git a/index.js b/index.js
@@
-const listener = (event, item, webContents) => {
+const listener = (event, item, webContents) => {
+	// Safe defaults even when `options` is undefined:
+	const {
+		retryLimit = 60,
+		// Drop this entirely unless you decide to document it:
+		toleranceBytes = 0
+	} = options ?? {};
 	let currentRetry = 0;
-	let itemLastTransferredBytes = 0;
+	let itemLastTransferredBytes = 0;
@@
- item.on('updated', (event, state) => {
- 	const retryLimit = options.retryLimit ?? 60;
- 	const toleranceBytes = options.toleranceBytes ?? 1024 * 50;
+	item.on('updated', (event, state) => {
 		receivedBytes = completedBytes;
@@
-	const itemTransferredBytes = item.getReceivedBytes();
-	const itemTotalBytes = item.getTotalBytes();
-	const hasTransferredBytesChanged = itemTransferredBytes > itemLastTransferredBytes + toleranceBytes;
-	itemLastTransferredBytes = itemTransferredBytes;
-	if (hasTransferredBytesChanged) {
-		currentRetry = 0;
-	}
+	const itemTransferredBytes = item.getReceivedBytes();
+	const itemTotalBytes = item.getTotalBytes();
+	// Simpler & correct: reset on any real progress during 'progressing'
+	if (state === 'progressing' && itemTransferredBytes > itemLastTransferredBytes + toleranceBytes) {
+		itemLastTransferredBytes = itemTransferredBytes;
+		currentRetry = 0;
+	}
@@
-	if (state === 'interrupted') {
-		if (item.canResume() && currentRetry < retryLimit) {
+	if (state === 'interrupted') {
+		if (item.canResume() && currentRetry < retryLimit) {
 			currentRetry++;
 			setTimeout(() => { item.resume(); }, 1000);
-		} else {
-			const message = pupa(errorMessage, {filename: path.basename(filePath)});
-			callback(new Error(message));
-			item.cancel();
-		}
+		} else {
+			// Preserve existing UX: surface the “interrupted” error instead of converting to “cancelled”.
+			const message = pupa(errorMessage, {filename: path.basename(filePath)});
+			// If running via download(...), reject; if registered globally, show the dialog.
+			if (callback !== noop) { // use the actual sentinel you already pass
+				callback(new Error(message));
+				// Do NOT cancel here; let 'done' emit 'interrupted' per Electron semantics.
+			} else {
+				// Global registered mode shows dialog per README; ensure that still happens.
+			}
+		}
 	}
 });

And make the resume scheduling single-shot:

- setTimeout(() => { item.resume(); }, 1000);
+ if (!retryTimer) {
+		retryTimer = setTimeout(() => { retryTimer = undefined; item.resume(); }, 1000);
+ }

Types/Docs

  • index.d.ts
// existing Options…
/** Number of 1s resume attempts after an interruption. @default 60 */
readonly retryLimit?: number;
/** Bytes of confirmed forward progress required to reset the retry counter. @default 0 */
readonly toleranceBytes?: number; // or drop it
  • README: add retryLimit (+ toleranceBytes if you keep it) under options, with defaults and behavior. The README already documents the “interrupted” error message UX; keep that intact.

Tests

  • Simulate an HTTP connection drop mid-download; assert automatic resume attempts up to the limit; assert final state stays interrupted when exhausted (global handler shows the documented error, manual download() rejects). Electron’s semantics: 'updated' emits state === 'interrupted' for resumable; 'done' emits state === 'interrupted' when not resumable. Use that in assertions. (Electron)

Misc

  • Consider exponential backoff after a few quick retries instead of fixed 1 s ticks.

Bottom line: keep the feature, fix the callback/cancel semantics, remove or document toleranceBytes, guard options, and align types/docs. After that, ship it. (GitHub)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants