Skip to content

Differential download must fail after 10 seconds #9063

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Mn4CaO5 opened this issue Apr 27, 2025 · 19 comments · Fixed by #9064 · May be fixed by #9071
Open

Differential download must fail after 10 seconds #9063

Mn4CaO5 opened this issue Apr 27, 2025 · 19 comments · Fixed by #9064 · May be fixed by #9071

Comments

@Mn4CaO5
Copy link

Mn4CaO5 commented Apr 27, 2025

When useMultipleRangeRequest is true, differential download must fail after 10 seconds.

DifferentialDownloader.ts:

private async doDownloadFile(tasks: Array<Operation>, fdList: Array<OpenedFile>): Promise<any> {
    ...

    await new Promise((resolve, reject) => {
      let w: any
      if (this.options.isUseMultipleRangeRequest) {
        w = executeTasksUsingMultipleRangeRequests(this, tasks, firstStream, oldFileFd, reject)
        w(0)
        return
      }

      ...
   })
  }

multipleRangeDownloader.ts:

export function executeTasksUsingMultipleRangeRequests(
  differentialDownloader: DifferentialDownloader,
  tasks: Array<Operation>,
  out: Writable,
  oldFileFd: number,
  reject: (error: Error) => void
): (taskOffset: number) => void {
  const w = (taskOffset: number): void => {
     ...

    const nextOffset = taskOffset + 1000
    doExecuteTasks(
      differentialDownloader,
      {
        tasks,
        start: taskOffset,
        end: Math.min(tasks.length, nextOffset),
        oldFileFd,
      },
      out,
      () => w(nextOffset),
      reject
    )
  }
  return w
}

function doExecuteTasks(differentialDownloader: DifferentialDownloader, options: PartListDataTask, out: Writable, resolve: () => void, reject: (error: Error) => void): void {
  ...

  const requestOptions = differentialDownloader.createRequestOptions()
  requestOptions.headers!.Range = ranges.substring(0, ranges.length - 2)
  const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
    if (!checkIsRangesSupported(response, reject)) {
      return
    }

    const contentType = safeGetHeader(response, "content-type")
    const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType)
    if (m == null) {
      reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`))
      return
    }

    const dicer = new DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, resolve)
    dicer.on("error", reject)
    response.pipe(dicer)

    response.on("end", () => {
      setTimeout(() => {
        request.abort()
        reject(new Error("Response ends without calling any handlers"))
      }, 10000) <-- The timer isn't canceled after data processing is completed.
    })
  })
  differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject)
  request.end()
}
@Mn4CaO5 Mn4CaO5 changed the title Differential downloads must fail after 10 seconds Differential download must fail after 10 seconds Apr 27, 2025
@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 27, 2025

2025-04-27 20:43:46.968 [info]  File has 18636 changed blocks
2025-04-27 20:43:47.108 [info]  Full: 707,054.48 KB, To download: 424,601.78 KB (60%)
2025-04-27 20:44:07.777 [error]         Cannot download differentially, fallback to full download: Error: Response ends without calling any handlers
    at Timeout._onTimeout (D:\Projects\desktop\node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js:90:24)
    at listOnTimeout (node:internal/timers:594:17)
    at process.processTimers (node:internal/timers:529:7)

#9064 cannot fix this bug.
@beyondkmp

@beyondkmp
Copy link
Collaborator

beyondkmp commented Apr 28, 2025

    response.on("end", () => {
      setTimeout(() => {
        request.abort()
        reject(new Error("Response ends without calling any handlers"))
      }, 10000)
    })

It appears that there is a 10-second timeout, and the server has not responded. You can manually test whether your server supports range requests, or if it times out when using range requests.

curl -v -L -H "Range: bytes=0-1023,2048-3071"  https://www.yourserver.com/youapp.exe

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 28, 2025

    response.on("end", () => {
      setTimeout(() => {
        request.abort()
        reject(new Error("Response ends without calling any handlers"))
      }, 10000)
    })

It appears that there is a 10-second timeout, and the server has not responded. You can manually test whether your server supports range requests, or if it times out when using range requests.

curl -v -L -H "Range: bytes=0-1023,2048-3071" https://www.yourserver.com/youapp.exe

My server should be normal, the test results are as follows:
Image

If the download process doesn't exceed 10s, the differential download is normal.
After the download process exceeds 10 seconds, the timer created when processing the first Range response is triggered.

@Mn4CaO5 Mn4CaO5 closed this as completed Apr 28, 2025
@Mn4CaO5 Mn4CaO5 reopened this Apr 28, 2025
@beyondkmp
Copy link
Collaborator

It shouldn’t be that, as downloading many large apps will definitely exceed 10 seconds. Currently, the timeout is set only after receiving the end event. It seems that your server has returned the first range and also replied with end?

  const requestOptions = differentialDownloader.createRequestOptions()
  requestOptions.headers!.Range = ranges.substring(0, ranges.length - 2)
  const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
    if (!checkIsRangesSupported(response, reject)) {
      return
    }

    const contentType = safeGetHeader(response, "content-type")
    const m = /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec(contentType)
    if (m == null) {
      reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`))
      return
    }

    const dicer = new DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, resolve)
    dicer.on("error", reject)
    response.pipe(dicer)

    response.on("end", () => {
      setTimeout(() => {
        request.abort()
        reject(new Error("Response ends without calling any handlers"))
      }, 10000)
    })
  })
  differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject)
  request.end()

What kind of server are you using? Nginx? Can you share the configuration so I can take a look?

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 28, 2025

It shouldn’t be that, as downloading many large apps will definitely exceed 10 seconds. Currently, the timeout is set only after receiving the end event. It seems that your server has returned the first range and also replied with end?

const requestOptions = differentialDownloader.createRequestOptions()
requestOptions.headers!.Range = ranges.substring(0, ranges.length - 2)
const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
if (!checkIsRangesSupported(response, reject)) {
return
}

const contentType = safeGetHeader(response, "content-type")
const m = /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec(contentType)
if (m == null) {
  reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`))
  return
}

const dicer = new DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, resolve)
dicer.on("error", reject)
response.pipe(dicer)

response.on("end", () => {
  setTimeout(() => {
    request.abort()
    reject(new Error("Response ends without calling any handlers"))
  }, 10000)
})

})
differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject)
request.end()

What kind of server are you using? Nginx? Can you share the configuration so I can take a look?

It seems like every Range response has an end event when I tested it.

My server is built based on openresty.

  1. mkdir -p ./openresty/conf ./openresty/logs ./openresty/webdav/updates
  2. place favicon.ico into ./openresty/webdav.
  3. docker pull openresty/openresty:latest
  4. docker run -d --name electron-updater-server
    -p 11001:11001
    -v ./openresty/conf/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf
    -v ./openresty/logs:/usr/local/openresty/nginx/logs
    -v ./openresty/webdav:/usr/local/openresty/nginx/webdav
    openresty/openresty:latest
  5. curl -X PUT http://127.0.0.1:11001/updates/test.txt -d "Hello, WebDAV!"

nginx.txt

@beyondkmp
Copy link
Collaborator

beyondkmp commented Apr 28, 2025

Okay, here's how you can configure Nginx to support multiple HTTP Range requests:

Nginx supports single HTTP Range requests (e.g., Range: bytes=0-1023) by default. If you want Nginx to handle requests containing multiple ranges (e.g., Range: bytes=0-499, 1000-1499) and respond with a multipart/byteranges content type, you need to use the max_ranges directive.

Add the max_ranges directive to the http, server, or location block where you want to enable this feature. Set its value to the maximum number of ranges you want to allow. Setting it to 0 disables multipart/byteranges responses entirely.

Here's an example configuration within a location block, allowing up to 1000 ranges:

server {
    listen 80;
    server_name your_domain.com; # Replace with your domain
    root /var/www/html;         # Replace with your document root

    location / {
        # ... other location directives ...

        # Set the maximum number of allowed ranges
        # 0: Disables multipart/byteranges
        # 1 (or default): Allows only a single range
        # >1: Allows multiple ranges, up to this number
        max_ranges 1000; # Allow up to 1000 ranges

        # Ensure Nginx can serve the files directly
        try_files $uri $uri/ =404;
    }

    # ... other server directives ...
}

Explanation:

  1. max_ranges 10;: This line tells Nginx to process up to 10 byte ranges specified in the Range request header. If a request contains more than 10 ranges, Nginx might ignore the Range header or return an error.
  2. Context: You can place max_ranges in the http block (global effect), server block (affects the entire virtual host), or location block (affects requests matching the location). Placing it in the specific location block serving large files is common.
  3. Prerequisite: Nginx needs to be serving the static files directly (e.g., using root or alias). For requests handled via proxy_pass, range processing is typically the responsibility of the backend server unless Nginx is configured with caching and has the full file cached.

After modifying the configuration, remember to reload or restart the Nginx service for the changes to take effect:

sudo nginx -t          # Test configuration syntax
sudo systemctl reload nginx # Reload configuration (recommended)
# or
sudo systemctl restart nginx # Restart Nginx service

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

Okay, here's how you can configure Nginx to support multiple HTTP Range requests:

Nginx supports single HTTP Range requests (e.g., Range: bytes=0-1023) by default. If you want Nginx to handle requests containing multiple ranges (e.g., Range: bytes=0-499, 1000-1499) and respond with a multipart/byteranges content type, you need to use the max_ranges directive.

Add the max_ranges directive to the http, server, or location block where you want to enable this feature. Set its value to the maximum number of ranges you want to allow. Setting it to 0 disables multipart/byteranges responses entirely.

Here's an example configuration within a location block, allowing up to 1000 ranges:

server {
listen 80;
server_name your_domain.com; # Replace with your domain
root /var/www/html; # Replace with your document root

location / {
    # ... other location directives ...

    # Set the maximum number of allowed ranges
    # 0: Disables multipart/byteranges
    # 1 (or default): Allows only a single range
    # >1: Allows multiple ranges, up to this number
    max_ranges 1000; # Allow up to 1000 ranges

    # Ensure Nginx can serve the files directly
    try_files $uri $uri/ =404;
}

# ... other server directives ...

}

Explanation:

1. **`max_ranges 10;`**: This line tells Nginx to process up to 10 byte ranges specified in the `Range` request header. If a request contains more than 10 ranges, Nginx might ignore the `Range` header or return an error.

2. **Context**: You can place `max_ranges` in the `http` block (global effect), `server` block (affects the entire virtual host), or `location` block (affects requests matching the location). Placing it in the specific `location` block serving large files is common.

3. **Prerequisite**: Nginx needs to be serving the static files directly (e.g., using `root` or `alias`). For requests handled via `proxy_pass`, range processing is typically the responsibility of the backend server unless Nginx is configured with caching and has the full file cached.

After modifying the configuration, remember to reload or restart the Nginx service for the changes to take effect:

sudo nginx -t # Test configuration syntax
sudo systemctl reload nginx # Reload configuration (recommended)

or

sudo systemctl restart nginx # Restart Nginx service

The result is still the same.

2025-04-29 11:28:58.132 [info]  File has 18636 changed blocks
2025-04-29 11:28:58.139 [info]  Full: 707,054.48 KB, To download: 424,601.78 KB (60%)
2025-04-29 11:29:08.967 [error]         Cannot download differentially, fallback to full download: Error: Response ends without calling any handlers
    at Timeout._onTimeout (D:\Projects\desktop\node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js:89:24)
    at listOnTimeout (node:internal/timers:594:17)
    at process.processTimers (node:internal/timers:529:7)

I've tried different nginx.conf.

user root;
worker_processes 4;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid       logs/nginx.pid;

events {
    worker_connections  1024;
}

http {
    large_client_header_buffers 4 256k;

    server {
        listen 11001;
        server_name localhost;

        root /usr/local/openresty/nginx/webdav;
        autoindex on;

        location / {
            # Set the maximum number of allowed ranges
            # 0: Disables multipart/byteranges
            # 1 (or default): Allows only a single range
            # >1: Allows multiple ranges, up to this number
            max_ranges 1000; # Allow up to 1000 ranges

            # Ensure Nginx can serve the files directly
            try_files $uri $uri/ =404;
        }

        location /updates {
            alias /usr/local/openresty/nginx/webdav/updates;
            dav_methods PUT DELETE MKCOL COPY MOVE;
            dav_access user:rw group:rw all:rw;
            create_full_put_path on;
            client_max_body_size 0;

            # auth_basic "Restricted Area";
            # auth_basic_user_file conf/htpasswd;

            access_log logs/webdav_access.log;
            error_log logs/webdav_error.log info;
        }
    }
}
user root;
worker_processes 4;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid       logs/nginx.pid;

events {
    worker_connections  1024;
}

http {
    large_client_header_buffers 4 256k;

    server {
        listen 11001;
        server_name localhost;

        root /usr/local/openresty/nginx/webdav;
        autoindex on;

        location /updates {
            # Set the maximum number of allowed ranges
            # 0: Disables multipart/byteranges
            # 1 (or default): Allows only a single range
            # >1: Allows multiple ranges, up to this number
            max_ranges 1000; # Allow up to 1000 ranges

            # Ensure Nginx can serve the files directly
            try_files $uri $uri/ =404;

            alias /usr/local/openresty/nginx/webdav/updates;
            dav_methods PUT DELETE MKCOL COPY MOVE;
            dav_access user:rw group:rw all:rw;
            create_full_put_path on;
            client_max_body_size 0;

            # auth_basic "Restricted Area";
            # auth_basic_user_file conf/htpasswd;

            access_log logs/webdav_access.log;
            error_log logs/webdav_error.log info;
        }
    }
}

@beyondkmp
Copy link
Collaborator

File has 18636 changed block

You can try setting max_ranges to 200000 and see what happens.

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

In addition, from the following source code, we can see that each HTTP Range response has an "end" event.

function doExecuteTasks(differentialDownloader: DifferentialDownloader, options: PartListDataTask, out: Writable, resolve: () => void, reject: (error: Error) => void): void {
  ...
  if (partCount <= 1) {
    // the only remote range - copy
    const w = (index: number): void => {
      if (index >= options.end) {
        resolve()
        return
      }

      const task = options.tasks[index++]

      if (task.kind === OperationKind.COPY) {
        copyData(task, out, options.oldFileFd, reject, () => w(index))
      } else {
        const requestOptions = differentialDownloader.createRequestOptions()
        requestOptions.headers!.Range = `bytes=${task.start}-${task.end - 1}`
        const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
          if (!checkIsRangesSupported(response, reject)) {
            return
          }

          response.pipe(out, {
            end: false,
          })
          response.once("end", () => w(index)) <-- Trigger the next request
        })
        differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject)
        request.end()
      }
    }

    w(options.start)
    return
  }
  ...
}

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

File has 18636 changed block

You can try setting max_ranges to 200000 and see what happens.

After setting max_ranges to 200000, the problem is solved.
However, the number of changed blocks is uncertain, so how to set max_ranges becomes a problem.

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

https://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges

The use of max_ranges is somewhat inconsistent with the description.

@beyondkmp
Copy link
Collaborator

https://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges

The use of max_ranges is somewhat inconsistent with the description.

Then I’m not sure. It might be related to the version of Nginx you’re using. Since you’re using OpenResty, you can try switching to the latest official Nginx version and see if that helps.

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

File has 18636 changed block

You can try setting max_ranges to 200000 and see what happens.

Sorry, I made a mistake. When max_ranges is 200000, the differential download time does not exceed 10s.
When verifying again, the differential download time exceeded 10s and the problem occurred again.

@beyondkmp
Copy link
Collaborator

However, the server running locally on your machine shouldn’t exceed 10 seconds, right? It still feels like there might be an issue with your configuration, or perhaps the server’s performance is too poor?

I tested it locally with nginx and didn’t encounter any issues. Moreover, we are already using it in our production environment with AWS CDN, and we haven’t encountered the issue you mentioned.

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

However, the server running locally on your machine shouldn’t exceed 10 seconds, right? It still feels like there might be an issue with your configuration, or perhaps the server’s performance is too poor?

I tested it locally with nginx and didn’t encounter any issues. Moreover, we are already using it in our production environment with AWS CDN, and we haven’t encountered the issue you mentioned.

Thanks for your help. Maybe you are right, I will check my server and configuration again.

@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 29, 2025

However, the server running locally on your machine shouldn’t exceed 10 seconds, right? It still feels like there might be an issue with your configuration, or perhaps the server’s performance is too poor?

I tested it locally with nginx and didn’t encounter any issues. Moreover, we are already using it in our production environment with AWS CDN, and we haven’t encountered the issue you mentioned.

I reproduced the bug on Cherry Studio(1.2.7->1.2.9). Here are the steps:

  1. modify multipleRangeDownload.js file in app.asar.
    node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js
function executeTasksUsingMultipleRangeRequests(differentialDownloader, tasks, out, oldFileFd, reject) {
    const w = (taskOffset) => {
        if (taskOffset >= tasks.length) {
            if (differentialDownloader.fileMetadataBuffer != null) {
                out.write(differentialDownloader.fileMetadataBuffer);
            }
            out.end();
            return;
        }
        const nextOffset = taskOffset + 3; <-- change 1000 to 3
        doExecuteTasks(differentialDownloader, {
            tasks,
            start: taskOffset,
            end: Math.min(tasks.length, nextOffset),
            oldFileFd,
        }, out, () => w(nextOffset), reject);
    };
    return w;
}

function doExecuteTasks(differentialDownloader, options, out, resolve, reject) {
    ...
    const requestOptions = differentialDownloader.createRequestOptions();
    requestOptions.headers.Range = ranges.substring(0, ranges.length - 2);
    const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
        if (!checkIsRangesSupported(response, reject)) {
            return;
        }
        const contentType = (0, builder_util_runtime_1.safeGetHeader)(response, "content-type");
        //const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType);
        const m = /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec(contentType);
        if (m == null) {
            reject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`));
            return;
        }
        const dicer = new DataSplitter_1.DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, (...args) => {console.log(`done[${options.start}]: ${Date.now()}`); resolve(...args)});
        dicer.on("error", reject);
        response.pipe(dicer);
        response.on("end", () => {
            console.log(`end[${options.start}]: ${Date.now()}`);
            setTimeout(() => {
                console.log(`timeout[${options.start}]: ${Date.now()}`);
                request.abort();
                reject(new Error("Response ends without calling any handlers"));
            }, 10000);
        });
    });
    differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject);
    request.end();
}
  1. use NetLimiter to limit the network speed of Cherry Studio.exe.
    The network speed is limited to 50KB/s.

  2. start Cherry Studio.exe via cmd

17:11:42.429 > File has 968 changed blocks
17:11:42.430 > [{"kind":0,"start":0,"end":78788},{"kind":1,"start":78788,"end":142146},{"kind":0,"start":142149,"end":303256},{"kind":1,"start":303253,"end":336021},{"kind":0,"start":336024,"end":4953492},{"kind":1,"start":4953489,"end":24487821},{"kind":0,"start":24113475,"end":26690112},{"kind":1,"start":27064458,"end":27228122},{"kind":0,"start":26853908,"end":27271937},{"kind":1,"start":27646151,"end":27698646},{"kind":0,"start":27322672,"end":83013970},{"kind":1,"start":83389944,"end":83413809},{"kind":0,"start":83037835,"end":83049238},{"kind":1,"start":83425212,"end":83589702},{"kind":0,"start":83213760,"end":94198022},{"kind":1,"start":94573964,"end":94595559},{"kind":0,"start":94219631,"end":94462208},{"kind":1,"start":94838136,"end":94997479}]
17:11:42.436 > Full: 92,770.98 KB, To download: 19,742.1 KB (21%)
done[3]: 1745918286672
end[3]: 1745918286672
end[9]: 1745918291570
done[9]: 1745918291586
timeout[3]: 1745918296685
17:18:16.685 > Cannot download differentially, fallback to full download: Error: Response ends without calling any handlers
    at Timeout._onTimeout (D:\Program Files\Cherry Studio\resources\app.asar\node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js:92:24)
    at listOnTimeout (node:internal/timers:581:17)
    at process.processTimers (node:internal/timers:519:7)
timeout[9]: 1745918301599
17:24:23.249 > Error: Error: net::ERR_CONTENT_LENGTH_MISMATCH
    at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/browser_init:2:114978)
    at SimpleURLLoaderWrapper.emit (node:events:519:28)

@beyondkmp
Copy link
Collaborator

It indeed looks like there is an issue. The setTimeout is not being cleared, so if the download of any one block takes more than 10 seconds, it will result in an error.

beyondkmp added a commit that referenced this issue Apr 30, 2025
…#9064)

The `Content-Type` response is as follows: `Content-Type:
multipart/byteranges; boundary=799ddd5a-943e-4e22-981d-e32596ca39d2`.
The `boundary` can have a space before it or no space at all.
Here's the rfc:
https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.1

![image](https://github.com/user-attachments/assets/16508142-3700-4297-ad64-57d121a153b5)

**Issue**

Currently, the check only accounts for cases where there is a space. If
some servers return a response without a space, it will cause
incremental downloads to fail.

**Error Info**
```
17:13:29.256 > Full: 92,770.98 KB, To download: 19,742.1 KB (21%)
17:13:29.633 > Cannot download differentially, fallback to full download: Error: Content-Type "multipart/byteranges" is expected, but got "multipart/byteranges;boundary=e888c103-165d-4ec1-9b6f-eca722ac9b10"
    at ClientRequest.<anonymous> (C:\Users\payne\AppData\Local\Programs\Cherry Studio\resources\app.asar\node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js:80:20)
    at ClientRequest.emit (node:events:531:35)
    at SimpleURLLoaderWrapper.<anonymous> (node:electron/js2c/browser_init:2:114720)
    at SimpleURLLoaderWrapper.emit (node:events:519:28)
17:13:34.288 > New version 1.2.9 has been downloaded to C:\Users\payne\AppData\Local\cherrystudio-updater\pending\Cherry-Studio-1.2.9-x64-setup.exe
```

**How to fix**

```
Welcome to Node.js v22.14.0.
Type ".help" for more information.
> /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec("multipart/byteranges;boundary=799ddd5a-943e-4e22-981d-e32596ca39d2")
[
  'multipart/byteranges;boundary=799ddd5a-943e-4e22-981d-e32596ca39d2',
  undefined,
  '799ddd5a-943e-4e22-981d-e32596ca39d2',
  index: 0,
  input: 'multipart/byteranges;boundary=799ddd5a-943e-4e22-981d-e32596ca39d2',
  groups: undefined
]
> /^multipart\/.+?\s*;\s*boundary=(?:"([^"]+)"|([^\s";]+))\s*$/i.exec("multipart/byteranges; boundary=799ddd5a-943e-4e22-981d-e32596ca39d2")
[
  'multipart/byteranges; boundary=799ddd5a-943e-4e22-981d-e32596ca39d2',
  undefined,
  '799ddd5a-943e-4e22-981d-e32596ca39d2',
  index: 0,
  input: 'multipart/byteranges; boundary=799ddd5a-943e-4e22-981d-e32596ca39d2',
  groups: undefined
]
```

might fix
#9063
@Mn4CaO5
Copy link
Author

Mn4CaO5 commented Apr 30, 2025

It indeed looks like there is an issue. The setTimeout is not being cleared, so if the download of any one block takes more than 10 seconds, it will result in an error.

Any plans to fix this bug?

@beyondkmp
Copy link
Collaborator

@tessro
I have made a fix. Could you help test it on your computer when you have time?

Replace all the content in node_modules\electron-updater\out\differentialDownloader\multipleRangeDownloader.js with the modified code below.

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeTasksUsingMultipleRangeRequests = executeTasksUsingMultipleRangeRequests;
exports.checkIsRangesSupported = checkIsRangesSupported;
const builder_util_runtime_1 = require("builder-util-runtime");
const DataSplitter_1 = require("./DataSplitter");
const downloadPlanBuilder_1 = require("./downloadPlanBuilder");
function executeTasksUsingMultipleRangeRequests(differentialDownloader, tasks, out, oldFileFd, reject) {
    const w = (taskOffset) => {
        if (taskOffset >= tasks.length) {
            if (differentialDownloader.fileMetadataBuffer != null) {
                out.write(differentialDownloader.fileMetadataBuffer);
            }
            out.end();
            return;
        }
        const nextOffset = taskOffset + 1000;
        doExecuteTasks(differentialDownloader, {
            tasks,
            start: taskOffset,
            end: Math.min(tasks.length, nextOffset),
            oldFileFd,
        }, out, () => w(nextOffset), reject);
    };
    return w;
}
function doExecuteTasks(differentialDownloader, options, out, resolve, reject) {
    let ranges = "bytes=";
    let partCount = 0;
    const partIndexToTaskIndex = new Map();
    const partIndexToLength = [];
    for (let i = options.start; i < options.end; i++) {
        const task = options.tasks[i];
        if (task.kind === downloadPlanBuilder_1.OperationKind.DOWNLOAD) {
            ranges += `${task.start}-${task.end - 1}, `;
            partIndexToTaskIndex.set(partCount, i);
            partCount++;
            partIndexToLength.push(task.end - task.start);
        }
    }
    if (partCount <= 1) {
        // the only remote range - copy
        const w = (index) => {
            if (index >= options.end) {
                resolve();
                return;
            }
            const task = options.tasks[index++];
            if (task.kind === downloadPlanBuilder_1.OperationKind.COPY) {
                (0, DataSplitter_1.copyData)(task, out, options.oldFileFd, reject, () => w(index));
            }
            else {
                const requestOptions = differentialDownloader.createRequestOptions();
                requestOptions.headers.Range = `bytes=${task.start}-${task.end - 1}`;
                const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
                    if (!checkIsRangesSupported(response, reject)) {
                        return;
                    }
                    response.pipe(out, {
                        end: false,
                    });
                    response.once("end", () => w(index));
                });
                differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, reject);
                request.end();
            }
        };
        w(options.start);
        return;
    }
    const requestOptions = differentialDownloader.createRequestOptions();
    requestOptions.headers.Range = ranges.substring(0, ranges.length - 2);
    let timeoutId = null;
    const wrappedResolve = () => {
        if (timeoutId !== null) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
        resolve();
    };
    const wrappedReject = (error) => {
        if (timeoutId !== null) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
        reject(error);
    };
    const request = differentialDownloader.httpExecutor.createRequest(requestOptions, response => {
        if (!checkIsRangesSupported(response, wrappedReject)) {
            return;
        }
        const contentType = (0, builder_util_runtime_1.safeGetHeader)(response, "content-type");
        const m = /^multipart\/.+?(?:; boundary=(?:(?:"(.+)")|(?:([^\s]+))))$/i.exec(contentType);
        if (m == null) {
            wrappedReject(new Error(`Content-Type "multipart/byteranges" is expected, but got "${contentType}"`));
            return;
        }
        const dicer = new DataSplitter_1.DataSplitter(out, options, partIndexToTaskIndex, m[1] || m[2], partIndexToLength, wrappedResolve);
        dicer.on("error", wrappedReject);
        response.pipe(dicer);
        response.on("end", () => {
            timeoutId = setTimeout(() => {
                timeoutId = null;
                request.abort();
                reject(new Error("Response ends without calling any handlers"));
            }, 30000);
        });
    });
    differentialDownloader.httpExecutor.addErrorAndTimeoutHandlers(request, wrappedReject);
    request.end();
}
function checkIsRangesSupported(response, reject) {
    // Electron net handles redirects automatically, our NodeJS test server doesn't use redirects - so, we don't check 3xx codes.
    if (response.statusCode >= 400) {
        reject((0, builder_util_runtime_1.createHttpError)(response));
        return false;
    }
    if (response.statusCode !== 206) {
        const acceptRanges = (0, builder_util_runtime_1.safeGetHeader)(response, "accept-ranges");
        if (acceptRanges == null || acceptRanges === "none") {
            reject(new Error(`Server doesn't support Accept-Ranges (response code ${response.statusCode})`));
            return false;
        }
    }
    return true;
}
//# sourceMappingURL=multipleRangeDownloader.js.map%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants