diff --git a/internal/output/progress.go b/internal/output/progress.go index cf249e1..18bab09 100644 --- a/internal/output/progress.go +++ b/internal/output/progress.go @@ -28,12 +28,13 @@ const ( // Progress displays a progress bar for operations with known counts type Progress struct { - total int64 - current int64 - message string - lastItem string - mu sync.Mutex - paused bool + total int64 + current int64 + message string + lastItem string + mu sync.Mutex + paused bool + lastShown int // last printed milestone bucket in non-tty mode } // NewProgress creates a new progress bar @@ -110,8 +111,30 @@ func (p *Progress) render() { } percent := int(current * 100 / total) - // Print at 0%, 25%, 50%, 75%, 100% - if current == 1 || percent == 25 || percent == 50 || percent == 75 || current == total { + // map current to a milestone bucket (0=none,1..5). concurrent workers + // hammer the same bucket, so only print when the bucket advances. + bucket := 0 + switch { + case current >= total: + bucket = 5 + case percent >= 75: + bucket = 4 + case percent >= 50: + bucket = 3 + case percent >= 25: + bucket = 2 + case current >= 1: + bucket = 1 + } + + p.mu.Lock() + advanced := bucket > p.lastShown + if advanced { + p.lastShown = bucket + } + p.mu.Unlock() + + if advanced { fmt.Printf(" [%d%%] %d/%d\n", percent, current, total) } return diff --git a/internal/output/progress_test.go b/internal/output/progress_test.go index 83833f7..dd1346e 100644 --- a/internal/output/progress_test.go +++ b/internal/output/progress_test.go @@ -12,7 +12,12 @@ package output -import "testing" +import ( + "os" + "strings" + "sync" + "testing" +) // the non-tty milestone path divides current*100/total, so a zero-total bar // used to panic with integer divide-by-zero when piped or redirected. @@ -32,3 +37,60 @@ func TestProgressCounts(t *testing.T) { t.Errorf("current = %d, want 4", p.current) } } + +// many concurrent workers used to spam the same milestone bucket (e.g. ten +// "[25%] .../1000" lines). each bucket must now print at most once. +func TestProgressNonTTYDedupesMilestones(t *testing.T) { + savedTTY, savedAPI := IsTTY, apiMode + IsTTY, apiMode = false, false + defer func() { IsTTY, apiMode = savedTTY, savedAPI }() + + out := captureStdout(t, func() { + p := NewProgress(1000, "scanning") + var wg sync.WaitGroup + for i := 0; i < 40; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 25; j++ { + p.Increment("x") + } + }() + } + wg.Wait() + }) + + lines := strings.Count(out, "\n") + if lines > 5 { + t.Errorf("printed %d milestone lines, want <=5:\n%s", lines, out) + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + saved := os.Stdout + os.Stdout = w + + done := make(chan string, 1) + go func() { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 1024) + for { + n, rerr := r.Read(tmp) + buf = append(buf, tmp[:n]...) + if rerr != nil { + break + } + } + done <- string(buf) + }() + + fn() + os.Stdout = saved + w.Close() + return <-done +}