[Bug] Extension-initiated downloads use 1 thread and can't be paused; manual add of the same URL uses all 64
Description
I found this bug while using the app but since I'm not a programmer I had claude investigate and confirm my suspicion.
When a download is started from the browser extension (clicking a link, then confirming the task dialog), the task runs with a single thread and cannot be paused - the pause button is disabled. Copying the exact same link and adding it manually in the app uses all 64 configured threads and is pausable/resumable.
The root cause is in the desktop app, not the extension: HttpParser.parse() skips its own Range-support probe whenever the caller supplies a file size, so an incorrect supportsRange: false from the extension is never re-checked and becomes permanent for that task.
The extension is the trigger (it reports supportsRange: false for a plain link click when the URL redirects before reaching the real file host), but the app is the defect - it has a working Range probe, uses it on the manual-add path, and simply doesn't run it on the extension path.
Steps to reproduce
- Settings: set PreBlockNum = 64. (I also had a speed limit enabled to make the behaviour easy to watch; the limit is not required to reproduce.)
- Open a page with a download link whose URL redirects before reaching the real file host. Ghost Downloader's own release asset works, so this is reproducible anywhere:
https://github.com/XiaoYouChR/Ghost-Downloader-3/releases/download/v4.3.7/Ghost-Downloader-v4.3.7-Windows-x86_64-Setup.exe - Run A - extension: click the download link in the browser. The extension intercepts it and the app shows the task dialog. Confirm.
- Task downloads with 1 thread, the pause button is disabled, and the progress bar is a single solid block (not segmented).
- Run B - manual: remove that task, copy the same URL, and add it manually with the app's add-task button.
- Task downloads with 64 threads, is pausable and resumable, and shows the segmented progress bar.
Both runs were done back to back against the same URL, same session, same settings.
Source URL
https://github.com/XiaoYouChR/Ghost-Downloader-3/releases/download/v4.3.7/Ghost-Downloader-v4.3.7-Windows-x86_64-Setup.exe (originally found with a different host, see Additional context)
Expected behavior
An extension-initiated task should use the same number of threads as a manually added task for the same URL, and should be pausable/resumable whenever the server supports Range requests.
More precisely: the app should never treat a negative supportsRange from an external caller as final. It already has a Range probe and already runs it on the manual path, so it should also run it whenever canUseRangeRequests is false. The cost is one bytes=1-1 request.
Screenshots
Both tasks are the same file, same settings, side by side:
- Top card (added via extension): a single blue segment filling from the left, no other segments, pause button disabled.
- Bottom card (added manually): many yellow segments across the whole bar, and the task is paused - i.e. pausing worked.
The segmented bar only renders when canUseRangeRequests is true (features/http_pack/cards.py:97), so the difference in the two progress bars is itself the flag.
Ghost Downloader version
v4.3.7
Browser extension version
2.2.0 (store install)
Operating system
Windows 11
OS version details
Windows 11 26H1 Build 28000.2704 - browser: Microsoft Edge 152.0.4191.66 (Official build, 64-bit), Chromium-based
Logs
The app's own logging already distinguishes the two paths, because HttpParser.parse() logs a line every time its Range probe actually runs.
Run B (manual add) - probe runs and succeeds:
2026-09-07 17:53:51.211 | INFO | http_pack:parse:120 - 偏移 Range 探测成功, content-range: bytes 1-1/91708865, fileSize: 91708865Run A (extension) - no probe line at all. The task starts and later stops with no Range 探测 entry in between, i.e. the probe never executed and canUseRangeRequests came entirely from the extension's payload.
Critically, the "server doesn't support Range, falling back to a single stream" warning never appears:
$ grep -c "降级为单流下载" GhostDownloader.log
0That message is features/http_pack/task.py:452 (服务器不支持范围请求,降级为单流下载), emitted when a running download discovers mid-flight that Range isn't honoured. Zero occurrences means the single-threaded run was not a legitimate runtime downgrade - the app never tested Range support at all.
Side effect visible in the same log - the auto-accelerator spins uselessly on the affected task:
2026-09-07 17:53:18.273 | INFO | http_pack.task:_autoSpeedUp:221 - 继续自动加速,subworker 增加比: 0.00%, 速度提升比: 0.00%
2026-09-07 17:53:25.270 | INFO | http_pack.task:_autoSpeedUp:221 - 继续自动加速,subworker 增加比: 0.00%, 速度提升比: 0.00%
2026-09-07 17:53:32.272 | INFO | http_pack.task:_autoSpeedUp:221 - 继续自动加速,subworker 增加比: 0.00%, 速度提升比: 0.00%_reassignSubworker() -> _splitSlowest() can never split the single subworker, because with canUseRangeRequests = False that subworker's end is SpecialFileSize.NOT_SUPPORTED (-1), so remainingBytes < 2 and it returns None. The ratio stays at 0.00% indefinitely.
Additional context
Root cause (desktop app)
features/http_pack/pack.py, HttpParser.parse():
canUseRangeRequests = False # L57
if isinstance(options, ResourceTaskOptions) and options.name: # L60
name = toSafeFilename(options.name, fallback=f"file_{time_ns()}")
fileSize = options.size if options.size > 0 else SpecialFileSize.UNKNOWN
canUseRangeRequests = options.canUseRangeRequests # L63 <-- trusts the caller
if fileSize == SpecialFileSize.UNKNOWN: # L65 <-- gated on SIZE only
...
statusCode, responseHeaders, finalUrl = await request("bytes=1-1")
canUseRangeRequests = statusCode == 206 and "content-range" in responseHeaders # L117The probe at L65 is gated on the file size being unknown, not on Range support being unknown. Consequences:
- Manual add - no
name, sofileSizestaysUNKNOWN-> probe runs ->206->canUseRangeRequests = True-> 64 subworkers. - Extension add - the extension supplies both
filenameandsize, sofileSize != UNKNOWN-> probe is skipped ->canUseRangeRequestskeeps the extension's value. If that value wasfalse, nothing ever corrects it.
The false value is then terminal in features/http_pack/task.py:
def _buildSubworkers(self) -> list[HttpSubworker]: # L128
if not self.canUseRangeRequests:
return [HttpSubworker(index=0, start=0, end=SpecialFileSize.NOT_SUPPORTED)]One subworker, permanently. The same single flag explains every symptom I observed, which is what convinced me this is one boolean and not several separate problems:
| Symptom | Code |
|---|---|
| 1 thread | task.py:129 _buildSubworkers early-returns a single subworker |
| Pause button disabled | task.py:76 canPause property returns canUseRangeRequests |
| Solid, not segmented, progress bar | cards.py:97 requires canUseRangeRequests and fileSize > 0 and subworkerCount > 1 |
No .ghd resume record written |
task.py:226 only opens the record file if self.canUseRangeRequests |
| Auto-accelerate stuck at 0.00% | _splitSlowest() cannot split a subworker whose end is -1 |
Why the extension reports supportsRange: false
browser_extension/app/src/background/resource-bridge.ts, routeBrowserDownload():
supportsRange: Boolean(
matchedResource?.supportsRange
|| headerSnapshot?.supportsRange
|| downloadItem.canResume === true,
),For a plain link click on a redirecting URL, all three inputs fail:
headerSnapshot.supportsRangeis set inonRequestHeaders()only if the browser's own request carried aRange:header. A normal link click does not send one.captureNetworkResource()(ononResponseStarted) does readAccept-Ranges/206from the response - but snapshots are keyed by exact URL. When the clicked URL 302s elsewhere, the snapshot stored under the original URL never sees theAccept-Rangesthat only the final host returns.downloadItem.canResumeis stillfalseatchrome.downloads.onDeterminingFilenametime (the Chromium path, used here).
Verified on this repo's own release asset:
$ curl -sSI 'https://github.com/XiaoYouChR/Ghost-Downloader-3/releases/download/v4.3.7/Ghost-Downloader-v4.3.7-Windows-x86_64-Setup.exe'
HTTP/2 302
location: https://release-assets.githubusercontent.com/github-production-release-asset/...
$ curl -sSI -r 1-1 'https://release-assets.githubusercontent.com/...'
HTTP/2 206
accept-ranges: bytes
content-range: bytes 1-1/43851384So the extension answers "no Range support" for a file that fully supports it. Worth fixing on its own, but redirects are completely normal on the web, which is why I think the durable fix belongs in the app.
Note on ADR-0001
browser_extension/app/src/background/download-spec.ts:4 states:
// ResourceTaskOptions 的权威。Per ADR-0001, the extension — not the desktop — decides the filename/size/supportsRange for a browser-sourced task ...
But docs/adr/0001-keep-speed-limit-enable-flag.md is about keeping isSpeedLimitEnabled separate from speedLimitation - it says nothing about the extension/desktop trust boundary. So the cited authority for trusting the extension's supportsRange doesn't appear to exist. Flagging it in case the intended ADR was never written or the reference is stale.
(For filename the extension genuinely is the better authority, since it has page context. For supportsRange it is strictly worse than a one-request probe.)
Suggested fix
Widen the gate so a negative Range result is always verified:
- if fileSize == SpecialFileSize.UNKNOWN:
+ # A caller-supplied `canUseRangeRequests=False` is a guess (the extension cannot see
+ # the final host across redirects), so verify it instead of trusting it. A supplied
+ # `True` is already backed by a real 206/Accept-Ranges observation.
+ if fileSize == SpecialFileSize.UNKNOWN or not canUseRangeRequests:One caveat with this approach: inside that block fileSize gets recomputed from the probe response, so a caller-supplied size could be overwritten with UNKNOWN if the probe returns something unexpected. Preserving it is probably worth a guard:
suppliedSize = fileSize # before the probe
...
if fileSize == SpecialFileSize.UNKNOWN:
fileSize = suppliedSize # after the probe, fall back to what the caller told usAn alternative (or additional) fix is to make supportsRange tri-state across the bridge - true / false / unknown - so the extension can say "I don't know" instead of being forced to assert false. That fixes the trust boundary properly but is a protocol change; the gate widening above is a one-line fix with the same practical effect.
Fixing the extension alone would close this particular case but leave the app trusting an unverifiable negative, so the next URL shape that defeats the three-way OR would reproduce it again.
Not browser-specific
I tested on Edge (Chromium). I don't believe this is Chromium-specific: the only cross-browser divergence in the extension is in shared/browser.ts (onSendHeadersExtraInfoSpec() omitting extraHeaders on Firefox, and supportsDownloadDeterminingFilename() choosing onDeterminingFilename vs onCreated), and neither affects the supportsRange decision. The app-side bug doesn't depend on the browser at all.
One thing worth checking separately: on Firefox the fallback is downloads.onCreated, which fires later than Chromium's onDeterminingFilename, so downloadItem.canResume might already be populated there and accidentally mask the bug via the third OR-branch. If so that's a Chromium-timing detail layered on the same app-side defect, not a separate issue.
Original reproducer
I first hit this on https://dl2.soft98.ir/soft/m/Mozilla.Firefox.155.0.1.EN.x64.zip?1788790441 (91708865 bytes), which redirects twice - dl2.soft98.ir -> dl2soft98.82.ir.cdn.ir -> edge11.82.ir.cdn.ir - with only the final edge returning Accept-Ranges: bytes and 206. That host may be geo-restricted to Iranian IPs, which is why I switched the reproducer above to this repo's own release asset. The bug is not specific to either link; any URL that redirects before the real file host should do it.
Source: XiaoYouChR/Ghost-Downloader-3