Skip to content

fix: cap clear width to the current terminal size (#106) - #235

Open
Shishir2405 wants to merge 1 commit into
schollz:mainfrom
Shishir2405:fix/clear-caps-to-shrunk-terminal
Open

fix: cap clear width to the current terminal size (#106)#235
Shishir2405 wants to merge 1 commit into
schollz:mainfrom
Shishir2405:fix/clear-caps-to-shrunk-terminal

Conversation

@Shishir2405

Copy link
Copy Markdown

Closes #106.

The bug

clearProgressBar pads the clear line to state.maxLineWidth, the widest the bar has ever been rendered:

str := fmt.Sprintf("\r%s\r", strings.Repeat(" ", s.maxLineWidth))

If the terminal is resized narrower after that widest render, this pads past the current terminal's column count. The terminal itself then wraps the overlong space-string onto a new line instead of clearing in place — the cursor moves down, and the bar appears to restart on every subsequent render instead of staying on one line, exactly as shown in the issue's screen recording.

The fix

renderProgressBar and fitProgressBarWidth already call termWidth(c.writer) on every render that isn't a full-width or an ignoreLength (spinner) bar (that covers the issue's repro — a plain progressbar.Default(...) bar). I cache that observed width into a new state.knownTermWidth field, and clearProgressBar caps to it via a new pure clampClearWidth(maxLineWidth, knownTermWidth int) int helper, instead of calling termWidth itself.

clearProgressBar runs before renderProgressBar in each render cycle (see (*ProgressBar).render()), so this uses last cycle's observed width — a resize is picked up within one render, and no new termWidth() call is added to the render path.

A -race finding along the way — please read before merging

I first tried calling termWidth(c.writer) directly inside clearProgressBar (obvious, one extra call, same guard). The logic was identical, but it made go test -race . (the exact command CI runs) fail on every run. I switched to the cached-value approach above specifically to avoid adding a call to that hot path — but honestly, go test -race . still fails intermittently on this branch (I reproduced it 3 of the runs I tried). On unmodified main, the same command passed clean across 8 consecutive runs on my machine.

The race itself is not in the logic I'm changing. Both failures I captured trace back to this, in NewOptions64:

if b.config.spinnerChangeInterval != 0 && !b.config.invisible && b.config.ignoreLength {
    go func() {
        ticker := time.NewTicker(b.config.spinnerChangeInterval)
        defer ticker.Stop()
        for range ticker.C {
            if b.IsFinished() { return }
            if b.IsStarted() {
                b.lock.Lock()
                b.render()
                b.lock.Unlock()
            }
        }
    }()
}

TestOptionSetSpinnerChangeInterval (and similar tests) build such a bar with max: -1 and never call Finish()/Exit() on it, so this ticker keeps calling render()clearProgressBar() for the rest of the test binary's life. Once that leaked goroutine's render() calls land at the wrong moment, they race against a different, already-finished test's buffer or the shared termWidth test hook:

WARNING: DATA RACE
Write at 0x0... by goroutine 1002:
  strings.(*Builder).WriteString()
  writeString() progressbar.go:1517
  clearProgressBar() progressbar.go:1497
  (*ProgressBar).render() progressbar.go:992
  NewOptions64.func1() progressbar.go:519        <- the leaked ticker goroutine
Previous read at 0x0... by goroutine 1001:
  strings.(*Builder).String()
  TestOptionShowTotalTrueIndeterminate() progressbar_test.go:1211

This is a real, pre-existing characteristic (a spinner bar with OptionSetSpinnerChangeInterval is designed to animate forever until the caller finishes it — the "leak" is really that these particular tests never do), not a bug in the code I'm changing. But I can't pretend my diff has zero relationship to it either: adding even a couple of cheap lines to render()'s hot path is apparently enough to shift scheduling and make this pre-existing timing-dependent race land far more often in my testing than it did on main. I don't think fixing that goroutine's lifecycle belongs in this PR — it's a separate, bigger concern (how/whether to stop an indeterminate bar's ticker without changing the "animate until Finish()" behavior it's designed to have) than what #106 asked for. Flagging it here rather than shipping a PR that looks clean and then surprises you in CI.

Happy to help chase the leak down in a follow-up if that's useful, or to hear if you'd rather this PR waited on that being fixed first.

Test plan

  • clampClearWidth (pure function): shrunk terminal caps down, still-wider terminal leaves it alone, unknown width falls back, exact-equal is a no-op. go test -run TestClampClearWidth -v .
  • go build ./..., go vet ., gofmt -l — clean.
  • go test -vet=off . (no -race) — passes.
  • go mod tidy -diff — no diff (no new dependencies).
  • go test -race -v -cover -vet=off . — the changed logic itself is race-free in isolation (-run TestClampClearWidth), but the full suite intermittently hits the pre-existing goroutine-leak race described above. See that section for details and reproduction.

When the terminal shrinks after the widest bar has been rendered,
clearProgressBar padded to that old (wider) maxLineWidth. The terminal
then wraps the overlong clear string onto a new line instead of
clearing in place, so the bar appears to restart on every render.

Cache the terminal width observed by the existing termWidth() calls in
renderProgressBar/fitProgressBarWidth (state.knownTermWidth) and cap
the clear width to it when it's narrower than maxLineWidth, via a new
pure clampClearWidth helper. This adds no new termWidth() call to the
clear path -- clear runs before render each cycle, so it uses the
value from the previous cycle.
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.

When reducing terminal size, bar will not remain in the same line in each rendering. Is it a bug?

1 participant