Skip to content

fix: make the download progress bar complete and stable on Windows and Linux - #495

Open
linuxsuren-fs wants to merge 4 commits into
LinuxSuRen:masterfrom
linuxsuren-fs:fix/progress-bar
Open

linuxsuren-fs wants to merge 4 commits into
LinuxSuRen:masterfrom
linuxsuren-fs:fix/progress-bar

Conversation

@linuxsuren-fs

@linuxsuren-fs linuxsuren-fs commented Oct 29, 2026 •

Copy link
Copy Markdown

What problems are fixed

The download progress bar was incomplete and unfriendly on both Windows and Linux, and a broken transfer failed the whole download:

  1. Multi-thread downloads garbled the terminal. Every part rendered its own progress bar while juggling them with relative cursor movement sequences (\r\033[NA) written raw to os.Stdout. On the classic Windows console (no VT processing) those sequences were printed literally as garbage, and on any terminal the start to download with N threads, size: ... line had no trailing newline, so the first bar immediately overwrote it. Errors were also printed from the downloading goroutines, tearing the bars apart.
  2. Unknown-size downloads showed no progress at all. progressbar was created with max = 0 when the server sends no Content-Length; Add64 then errors on every write (max must be greater than 0), so nothing was ever rendered for the whole download (chunked responses, --show-progress edge cases).
  3. The display was incomplete. No transferred/total byte counters were shown (OptionShowCount was never set), and the color description tag was malformed ([cyan][reset] with an empty colored segment).
  4. Unfriendly finishing. After a multi-thread download the code slept for 1s + 5s and emitted another raw ANSI sequence (\033[%dE) before continuing, and the completed bar never moved the cursor to the next line, so follow-up log lines appended to the bar.
  5. A broken transfer failed the whole download. When a part's stream broke in the middle (unexpected EOF), the download just failed, although the already transferred bytes were on the disk.
  6. hd get blocked on a broken empty output file. An interrupted multi-thread download could leave a 0-byte output file behind, and the next run refused to continue without --force. The --max-attempts flag was not wired to anything.

How it is fixed

Progress rendering

  • Interactive terminal: one progress bar per part (MultiProgressIndicator). All the bars and the relative cursor movements go through a single locked renderer on the ANSI-aware output (go-ansi enables VT processing on Windows), so every part keeps its own stable line:

    start to download with 4 threads, size: 8.00 MB
    part 1/4 100% [====================================================] (2.0/2.0 MB, 4.1 MB/s) [0s]
    part 2/4 100% [====================================================] (2.0/2.0 MB, 4.1 MB/s) [0s]
    part 3/4 100% [====================================================] (2.0/2.0 MB, 4.1 MB/s) [0s]
    part 4/4 100% [====================================================] (2.0/2.0 MB, 4.1 MB/s) [0s]
    downloaded: out.bin
    
  • Piped or redirected output: one aggregated bar fed by all the parts, which needs no cursor movements at all, so logs and CI output stay clean.

  • Unknown size (Total <= 0) creates the bar in spinner mode (max = -1) which reports the transferred bytes and the speed for the whole download.

  • The bars now show transferred/total MB, speed and the elapsed time, print a newline on completion so the prompt starts on a fresh line, and rendering is throttled to 65ms.

  • MultiThreadDownloader prints the threads/size line with a proper newline (human readable size), collects part errors instead of printing them mid-download, cancels the remaining parts on the first failure, removes the 6-second sleeps and the raw ANSI fixups, and removes the broken output file when concatenating the parts failed.

  • hd get treats an existing empty output file as a broken download and overwrites it without --force (a non-empty file still requires --force).

  • --show-progress=false no longer creates any bar at all.

Note: closing an already finished progressbar/v3 bar erases its final frame (the renderer clears the line first and then skips the re-render of a finished bar), so only the unfinished bars get finished when the group closes.

Automatic retry and resume (断点续传)

  • DownloadWithContinue retries a transfer that broke in the middle (e.g. unexpected EOF) and resumes from the bytes that are already on the local disk: the part file is re-opened in append mode and the request continues with Range: bytes=<have>-<end>.
  • The retry count comes from the so far unused --max-attempts flag (default 10), now plumbed through ContinueDownloader and MultiThreadDownloader (it applies per download part).
  • Error classification: client errors (404, 403...) and canceled downloads are not retried; server errors (5xx), rate limiting (429) and network errors are.
  • If the server ignores the range request on a resumed download (200 instead of 206), the download fails with a clear error instead of appending the full body to the partial file (which would corrupt it).
  • The already-downloaded bytes count into the progress bar (ProgressBase), so a resumed bar reflects the whole file instead of only the remaining part.
  • The waiting between the attempts respects the context, so a canceled download stops retrying immediately.

Verification

Unit tests (pkg/net/progress_test.go, pkg/net/retry_resume_test.go): shared-bar aggregation (including a concurrent multi-part stress test), unknown-size handling, nil/no-bar passthrough, multi-bar line management (cursor up/down sequences, per-part byte counters, close semantics), humanBytes formatting, resume from a broken transfer (range headers, appended file content), no-retry on client errors, retry exhaustion, range-ignored protection against file corruption, cancelation while waiting, and the retryable-error classification. go test ./pkg/... passes (the pkg/installer OCI and pkg/os failures are pre-existing on Windows, they fail on master too).

Integration testing against a local HTTP server (Range support + a chunked no-Content-Length route + routes that break the first transfer in the middle):

  • Linux (WSL, real pty via script, 100 columns): the per-part bars render on dedicated lines and animate to 100% without garbling each other; with a part broken mid-transfer the part resumes automatically and still reaches 100% (visible via its lower average speed), final screen replayed through a vt10x virtual terminal.
  • Windows: known-size single-thread and multi-thread downloads show one clean full-width line ending at 100%; a broken single-thread transfer and a broken multi-thread part both auto-resume and produce byte-identical files (SHA256 verified); chunked download shows the spinner with the transferred bytes; --show-progress=false emits no escape sequences at all; a pre-existing 0-byte output file gets overwritten with a log line.
  • Downloaded files from all scenarios are byte-identical across platforms and modes (SHA256 verified).

…d Linux

- multi-thread downloads now feed one aggregated progress bar instead of
  juggling N bars with relative cursor movement, which printed garbage
  (e.g. literal escape sequences) on the classic Windows console
- print the threads/size line with a proper newline so the progress bar
  no longer overwrites it
- unknown-size downloads (no Content-Length) render a spinner with the
  transferred bytes instead of showing nothing at all
- show the transferred/total byte counters, the speed and the elapsed
  time, and leave the prompt on the next line once completed
- remove the 6-second post-download sleeps and the raw ANSI cursor fixes
- collect the part download errors instead of printing them during the
  download, and cancel the remaining parts once one part failed
@codacy-production

codacy-production Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 85 complexity · 14 duplication

Metric Results
Complexity 85
Duplication 14

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

- on a terminal the multi-thread download now renders one bar per part
  (part 1/4, part 2/4, ...), all the bars and the relative cursor
  movements go through a single locked ANSI-aware renderer, so the lines
  never garble each other on Windows and Linux
- a piped or redirected output falls back to one aggregated bar, keeping
  the logs free of cursor control sequences
- fix the bars vanishing at the end: closing an already finished bar
  erases its final frame (the renderer clears the line first and skips
  the re-render), only the unfinished bars get finished now
- hd get: treat an existing empty output file as a broken download and
  overwrite it without asking for --force
- multi-thread download: do not leave a broken (e.g. empty) output file
  behind when concatenating the parts failed
- DownloadWithContinue retries a transfer that broke in the middle
  (e.g. an unexpected EOF) and resumes from the bytes that are already
  on the local disk via range requests, instead of failing the whole
  download
- the retry count comes from the so far unused --max-attempts flag
  (default 10), plumbed through MultiThreadDownloader as well
- client errors (404, 403) are not retried, server errors and rate
  limiting are; a canceled download is never retried
- if the server ignores the range request on a resumed download (200
  instead of 206), fail instead of appending the full body to the
  partial file (which would corrupt it)
- the resumed bytes count into the progress bar, so it reflects the
  whole file instead of only the remaining part
@LinuxSuRen

Copy link
Copy Markdown
Owner

Thanks for the thorough PR — the design (locked multi-bar renderer via go-ansi, aggregated bar for piped output, spinner for unknown size, 200-vs-206 corruption guard on resume) looks right, and the new tests in progress_test.go / retry_resume_test.go are solid. I verified the patch locally: build, go vet, golint clean, go test ./pkg/net/ ./cmd/ green on macOS.

Two findings block the CI, plus smaller notes:

🔴 Blocking — CI red

1. pkg/net/http.go:236 and :245 — gosec G301/G302 (Security job fails)

  • os.MkdirAll(path.Dir(l.path), os.FileMode(0755)) → use 0750
  • os.OpenFile(l.path, flags, 0644) → use 0600
    Note: PR feat: support install package on window git-bash #485 also carries the 0755→0750 change for the same line — whichever merges second will conflict; fine either way, but worth sequencing.

2. cmd/get_test.go "download from an URL with multi-threads" — TestAndReport fails (Linux CI)
The mock returns one shared *http.Response whose Body is single-use via .AnyTimes(). Both part goroutines race to read it: the loser gets an already-consumed body → io.Copy returns 0 bytes / nil error → with the lazy file writer no part file is created → concat fails → os.RemoveAll(targetFilePath) deletes the output → runE then runs os.Chmod on the missing file (the chmod error masks the real one, because it overwrites err unconditionally at cmd/get.go:356).
Fix the same way as the already-fixed TestMultiThreadDownloader (pkg/net/http_test.go): use .DoAndReturn returning a fresh response with a fresh body per call. Consider also only chmod-ing when err == nil.

🟡 Important

3. isRetryable (pkg/net/http.go:495) retries filesystem errors. ENOSPC/EACCES from the lazy file writer are not *DownloadError, so they fall into the "network error → retry" branch and burn all 10 attempts with 500ms sleeps on a condition that cannot recover. Suggest treating *fs.PathError as non-retryable.

4. Empty-file auto-overwrite (cmd/get.go:304) is a user-visible behavior change: a deliberately created empty placeholder file gets silently overwritten without --force. The rationale (broken downloads block the next run) is sound and the concat path now removes broken outputs, so I can live with it — but please mention it in the flag/help or README so it is a documented decision, not a surprise.

🟢 Suggestions

5. WithProgressIndicator doc says passing a non-Init-ed indicator disables the progress; in DownloadAsStream a nil shared indicator with ShowProgress=true actually creates a fresh standalone bar per part (back to garbling). Only reachable by API misuse, but the doc and behavior disagree.
6. DownloadWithContinue retry loop: when the part file does not exist (downloaded == 0), attempt N replays byte-for-byte the same request as attempt 1 — harmless but wasteful; could short-circuit if the error is a 4xx.
7. cmd/get.go has a pre-existing gofmt misalignment (Username/Password fields, not from this PR) — nice to fix in passing.

Everything else in the diff (per-part bars registered before goroutine start for stable line order, only-owner-closes bar semantics, cancellation between attempts, byte counters) checks out. With 1–2 addressed (and ideally 3) this is mergeable from my side; merging stays with the maintainer.

- lazyFileWriter: create the directory with 0750 and the file with 0600
  (gosec G301/G302)
- get_test: return a fresh response with a fresh body per round trip in
  the multi-threads case, the shared single-use body made the parallel
  parts race (TestRunE failed on the Linux CI); chmod the output only
  after a successful download so a chmod error cannot mask the download
  error
- do not retry local filesystem errors (e.g. ENOSPC, EACCES), a resumed
  request cannot fix them
- document the empty-output-file auto-overwrite in the --force flag help
- keep the progress disabled when an injected indicator was not
  initialized, instead of falling back to standalone bars per part
- gofmt the downloadOption struct fields (pre-existing misalignment)
@linuxsuren-fs

Copy link
Copy Markdown
Author

Thanks a lot for the careful review! All findings are addressed in 3a2eeb8 (plus replies below):

1. gosec G301/G302 — fixed: the directory is created with 0750 and the file with 0600. (Noted on the potential conflict with #485, happy to rebase once either side lands.)

2. TestRunE multi-threads race — fixed the same way as TestMultiThreadDownloader: the mock now uses .DoAndReturn and returns a fresh response with a fresh body per call. Also took your chmod suggestion: cmd/get.go now only chmods when err == nil, so the real download error is never masked. Verified by cross-compiling the cmd test binary and running it on Linux (WSL): TestRunE/download_from_an_URL_with_multi-threads passes now — while it fails on current master in the same environment, which confirms the race you diagnosed. (TestDownloadMagnetFile fails identically on master in this sandbox, so I left it alone.)

3. isRetryable burning attempts on filesystem errors — fixed: *fs.PathError (ENOSPC, EACCES, ...) is now classified as non-retryable, with unit tests covering both cases.

4. Empty-file auto-overwrite documentation — documented in the --force flag help: "Overwrite the exist file if this is true. An empty output file is always taken as a broken download and overwritten without this flag". That felt like the most discoverable place; tell me if you'd prefer a README section as well.

5. WithProgressIndicator doc vs behavior — behavior now matches the doc: HTTPDownloader carries an explicit progressInjected marker, so an injected-but-not-initialized indicator disables the progress entirely instead of falling back to standalone bars per part (covered by TestDownloadWithContinueUninitializedProgressDisablesBar).

6. Wasteful replay when the part file does not exist — the 4xx short-circuit you suggest is already in place: isRetryable returns false for client errors, so the loop breaks immediately instead of replaying the same request. When downloaded == 0 with a retryable network error the identical replay is the desired retry. No change needed, but happy to reconsider if I misunderstood the point.

7. downloadOption misalignment — fixed via gofmt in passing.

go build ./..., go vet ./pkg/net/..., go test ./pkg/net/... ./pkg/ are green locally, and the cmd suite was exercised on Linux as described above.

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