Files
dualfroz 18ce81580f fix: do not corrupt state when Add/Set overshoots max
Add64 checked currentNum > max only after already mutating currentNum
and currentBytes, so a single call that overshoots max returned an
error but left the bar stuck: currentNum frozen above max, percent
stuck above 100%, and every later Add/Set call failing with the same
error forever, since the top-of-function currentNum < max guard could
never pass again.

Check the bound before mutating state instead, so an overshooting call
is rejected as a whole and leaves currentNum/currentBytes/currentPercent
exactly as they were. The now-unreachable post-hoc check is removed.

Add TestAddExceedsMaxDoesNotCorruptState covering the corrupted-state
case and that the bar keeps accepting valid progress afterwards.
2026-09-04 08:23:03 +02:00

4.5 KiB

Title: Fix progress bar getting permanently stuck after an overshooting Add/Set call

Problem

Add64 (progressbar.go:724, reached by Add, Add64, Set and Set64) is meant to reject a call that would push the counter past max and leave the bar unchanged:

if p.state.currentNum < p.config.max {
    ...
    p.state.currentNum += num
}
...
if p.state.currentNum > p.config.max {
    return errors.New("current number exceeds max")
}

The bounds check runs after currentNum (and currentBytes) have already been mutated, so a single call that overshoots max is not rejected atomically: the error is returned, but the invalid, over-max state is kept. Once that happens the bar is stuck: currentNum never advances again, because the guard at the top of the function (currentNum < max) is now permanently false, and currentPercent/the saucer width stay frozen above 100%. Every subsequent Add or Set call, even ones that would otherwise be perfectly valid, keeps returning "current number exceeds max" forever, with no way to recover short of Reset().

Reproduced on a clean checkout with:

bar := New(100)
bar.Add(95)                  // currentNum=95, no error
bar.Add(10)                  // currentNum=105, err="current number exceeds max"
bar.Add(1)                   // currentNum still 105, same error again -- stuck forever

This is reachable through the public API with nothing exotic: any caller whose increments do not sum to exactly max (a very common case, e.g. an estimated total that is off by a few bytes, or a Set() racing another goroutine's Add()) will hit this on the final update, at 95-105% of the way through, and never see the bar reach 100% or accept further progress again.

Fix

Move the bounds check before the mutation, so an overshooting call is rejected as a whole and leaves currentNum/currentBytes/currentPercent exactly as they were before the call:

if !p.config.ignoreLength && p.state.currentNum+num > p.config.max {
    return errors.New("current number exceeds max")
}

The ignoreLength mode is left out of the new guard on purpose: it advances with (currentNum + num) % max, which by construction wraps and never exceeds max, so the check would never trigger there anyway; excluding it keeps that code path untouched. The old post-hoc check at the end of the function is now unreachable in the non-ignoreLength path (the mutation it used to guard can no longer produce an out-of-range value) and is removed rather than left as dead code.

Behavior change worth flagging: previously, once currentNum reached exactly max, further Add(n) calls with n > 0 were silently absorbed with no error (currentNum simply stopped advancing, though currentBytes kept drifting upward unnoticed). With this fix such calls now consistently return the same "current number exceeds max" error as any other overshoot, which matches what the error message already claims and is the only existing test's expectation (TestBar, which only asserts that an error is returned, not any particular resulting state).

Added TestAddExceedsMaxDoesNotCorruptState in progressbar_test.go, next to the existing TestBar overshoot test, asserting that a rejected Add leaves currentNum/currentPercent unchanged and that the bar keeps accepting valid progress afterwards.

Verification

  • go build ./...: exit 0.
  • go vet . (this package): exit 0. go vet ./... (whole repo) exits 1, but solely because of a pre-existing, unrelated issue in examples/download-unknown/main.go:14 ("using resp before checking for errors"), confirmed present before this change via git stash; not touched here.
  • gofmt -l .: no output (clean).
  • go test ./... -count=1 -v: exit 0, 33 top-level PASS, 0 FAIL, 0 SKIP.
  • Counterfactual: reverting only the Add64 fix (keeping the new test) fails it with expected currentNum to stay at 95 after a rejected Add, got 105, matching the reproduction above; re-applying the fix restores a clean pass.

Flags for account owner

  • The "silently absorbed at exactly max" -> "consistently rejected" behavior change described above is the one substantive semantic change beyond the corruption fix; flagging it explicitly in case the maintainer wants it called out in a changelog entry.
  • Two unrelated open PRs (#235, #237) touch progressbar.go/progressbar_test.go in different functions (clearProgressBar/render visibility); checked their diffs, no line overlap with this change.