Compare commits

...

1 Commits

Author SHA1 Message Date
vmfunc 094f1e7806 fix(output): dedupe non-tty progress milestones
concurrent workers (-threads 40) all hit the same milestone bucket on
increment, spamming ~10 duplicate [25%] lines. track the last printed
bucket under p.mu and only print when it advances.
2026-06-09 16:03:52 -07:00
2 changed files with 94 additions and 9 deletions
+31 -8
View File
@@ -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
+63 -1
View File
@@ -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
}