Skip to content

Commit f05e1de

Browse files
authored
progress: address race conditions in render/stop/trackers; fixes 399 (#401)
1 parent 1cebbc5 commit f05e1de

File tree

7 files changed

+77
-26
lines changed

7 files changed

+77
-26
lines changed

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ test: fmt vet lint cyclo
2626
test-no-lint: fmt vet cyclo
2727
go test -cover -coverprofile=.coverprofile ./...
2828

29-
## test-race: Run progress demo with race detector
29+
## test-race: Run tests and progress demo with race detector
3030
test-race:
31-
go run -race ./cmd/demo-progress/demo.go
31+
go test -race ./...
32+
go run -race ./cmd/demo-progress
3233

3334
# ============================================================================
3435
# Code quality targets

progress/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ A demonstration of all the capabilities can be found here:
2626
### Tracker Management
2727

2828
- Dynamically add one or more Task Trackers while `Render()` is in progress
29-
- Sort trackers by Message, Percentage, or Value (ascending/descending)
29+
- Sort trackers by Index (ascending/descending), Message, Percentage, or Value
30+
- `SortByIndex` / `SortByIndexDsc` - Sort by explicit Index field, maintaining
31+
order regardless of completion status (done and active trackers are merged
32+
and sorted together)
33+
- For other sorting methods, done and active trackers are sorted separately,
34+
with done trackers always rendered before active trackers
3035
- Tracker options
36+
- `AutoStopDisabled` - Prevent auto-completion when value exceeds total
3137
- `DeferStart` - Delay tracker start until manually triggered
38+
- `Index` - Explicit ordering value for trackers (used with `SortByIndex`)
3239
- `RemoveOnCompletion` - Hide tracker when done instead of showing completion
33-
- `AutoStopDisabled` - Prevent auto-completion when value exceeds total
3440

3541
### Display & Rendering
3642

progress/progress.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Progress struct {
3232
logsToRenderMutex sync.RWMutex
3333
numTrackersExpected int64
3434
outputWriter io.Writer
35+
outputWriterMutex sync.RWMutex
3536
overallTracker *Tracker
3637
overallTrackerMutex sync.RWMutex
3738
pinnedMessages []string
@@ -199,7 +200,9 @@ func (p *Progress) SetNumTrackersExpected(numTrackers int) {
199200
// os.Stdout or os.Stderr or a file. Warning: redirecting the output to a file
200201
// may not work well as the Render() logic moves the cursor around a lot.
201202
func (p *Progress) SetOutputWriter(writer io.Writer) {
203+
p.outputWriterMutex.Lock()
202204
p.outputWriter = writer
205+
p.outputWriterMutex.Unlock()
203206
}
204207

205208
// SetPinnedMessages sets message(s) pinned above all the trackers of the
@@ -344,16 +347,19 @@ func (p *Progress) initForRender() {
344347
}
345348

346349
// if not output write has been set, output to STDOUT
350+
p.outputWriterMutex.RLock()
347351
if p.outputWriter == nil {
348352
p.outputWriter = os.Stdout
349353
}
354+
outputWriter := p.outputWriter
355+
p.outputWriterMutex.RUnlock()
350356

351357
// pick a sane update frequency if none set
352358
if p.updateFrequency <= 0 {
353359
p.updateFrequency = DefaultUpdateFrequency
354360
}
355361

356-
if p.outputWriter == os.Stdout {
362+
if outputWriter == os.Stdout {
357363
// get the current terminal size for preventing roll-overs, and do this in a
358364
// background loop until end of render. This only works if the output writer is STDOUT.
359365
go p.watchTerminalSize() // needs p.updateFrequency

progress/render.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,17 @@ func (p *Progress) renderTrackers(lastRenderLength int) int {
382382
}
383383

384384
// write the text to the output writer
385+
p.outputWriterMutex.Lock()
385386
_, _ = p.outputWriter.Write([]byte(out.String()))
387+
p.outputWriterMutex.Unlock()
386388

387389
// stop if auto stop is enabled and there are no more active trackers
388390
if p.autoStop && p.LengthActive() == 0 {
389-
p.renderContextCancel()
391+
p.renderContextCancelMutex.Lock()
392+
if p.renderContextCancel != nil {
393+
p.renderContextCancel()
394+
}
395+
p.renderContextCancelMutex.Unlock()
390396
}
391397

392398
return out.Len()
@@ -498,19 +504,23 @@ func (p *Progress) renderTrackerStatsSpeed(out *strings.Builder, t *Tracker, hin
498504

499505
p.trackersActiveMutex.RLock()
500506
for _, tracker := range p.trackersActive {
501-
if !tracker.timeStart.IsZero() {
502-
speed += float64(tracker.Value()) / time.Since(tracker.timeStart).Round(speedPrecision).Seconds()
507+
timeStart := tracker.timeStartValue()
508+
if !timeStart.IsZero() {
509+
speed += float64(tracker.Value()) / time.Since(timeStart).Round(speedPrecision).Seconds()
503510
}
504511
}
505512
p.trackersActiveMutex.RUnlock()
506513

507514
if speed > 0 {
508515
p.renderTrackerStatsSpeedInternal(out, p.style.Options.SpeedOverallFormatter(int64(speed)))
509516
}
510-
} else if !t.timeStart.IsZero() {
511-
timeTaken := time.Since(t.timeStart)
512-
if timeTakenRounded := timeTaken.Round(speedPrecision); timeTakenRounded > speedPrecision {
513-
p.renderTrackerStatsSpeedInternal(out, t.Units.Sprint(int64(float64(t.Value())/timeTakenRounded.Seconds())))
517+
} else {
518+
timeStart := t.timeStartValue()
519+
if !timeStart.IsZero() {
520+
timeTaken := time.Since(timeStart)
521+
if timeTakenRounded := timeTaken.Round(speedPrecision); timeTakenRounded > speedPrecision {
522+
p.renderTrackerStatsSpeedInternal(out, t.Units.Sprint(int64(float64(t.Value())/timeTakenRounded.Seconds())))
523+
}
514524
}
515525
}
516526
}
@@ -528,11 +538,12 @@ func (p *Progress) renderTrackerStatsSpeedInternal(out *strings.Builder, speed s
528538

529539
func (p *Progress) renderTrackerStatsTime(outStats *strings.Builder, t *Tracker, hint renderHint) {
530540
var td, tp time.Duration
531-
if !t.timeStart.IsZero() {
541+
timeStart, timeStop := t.timeStartAndStop()
542+
if !timeStart.IsZero() {
532543
if t.IsDone() {
533-
td = t.timeStop.Sub(t.timeStart)
544+
td = timeStop.Sub(timeStart)
534545
} else {
535-
td = time.Since(t.timeStart)
546+
td = time.Since(timeStart)
536547
}
537548
}
538549
if hint.isOverallTracker {

progress/render_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"regexp"
66
"sort"
77
"strings"
8+
"sync"
89
"testing"
910
"time"
1011

@@ -19,13 +20,18 @@ var (
1920

2021
type outputWriter struct {
2122
Text strings.Builder
23+
mu sync.Mutex
2224
}
2325

2426
func (w *outputWriter) Write(p []byte) (n int, err error) {
27+
w.mu.Lock()
28+
defer w.mu.Unlock()
2529
return w.Text.Write(p)
2630
}
2731

2832
func (w *outputWriter) String() string {
33+
w.mu.Lock()
34+
defer w.mu.Unlock()
2935
return w.Text.String()
3036
}
3137

@@ -331,7 +337,7 @@ func TestProgress_generateTrackerStr_Indeterminate(t *testing.T) {
331337
}
332338

333339
func TestProgress_RenderNeverStarted(t *testing.T) {
334-
renderOutput := strings.Builder{}
340+
renderOutput := outputWriter{}
335341

336342
pw := generateWriter()
337343
pw.SetOutputWriter(&renderOutput)

progress/tracker.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,20 @@ func (t *Tracker) valueAndTotal() (int64, int64) {
264264
t.mutex.RUnlock()
265265
return value, total
266266
}
267+
268+
// timeStartAndStop returns the start and stop times safely.
269+
func (t *Tracker) timeStartAndStop() (time.Time, time.Time) {
270+
t.mutex.RLock()
271+
timeStart := t.timeStart
272+
timeStop := t.timeStop
273+
t.mutex.RUnlock()
274+
return timeStart, timeStop
275+
}
276+
277+
// timeStartValue returns the start time safely.
278+
func (t *Tracker) timeStartValue() time.Time {
279+
t.mutex.RLock()
280+
timeStart := t.timeStart
281+
t.mutex.RUnlock()
282+
return timeStart
283+
}

progress/tracker_sort.go

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func (sb sortByIndex) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
6969
func (sb sortByIndex) Less(i, j int) bool {
7070
if sb[i].Index == sb[j].Index {
7171
// Same index: maintain insertion order (use timeStart as tiebreaker)
72-
return sb[i].timeStart.Before(sb[j].timeStart)
72+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
7373
}
7474
return sb[i].Index < sb[j].Index
7575
}
@@ -81,7 +81,7 @@ func (sb sortByIndexDsc) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
8181
func (sb sortByIndexDsc) Less(i, j int) bool {
8282
if sb[i].Index == sb[j].Index {
8383
// Same index: maintain insertion order (earlier timeStart first)
84-
return sb[i].timeStart.Before(sb[j].timeStart)
84+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
8585
}
8686
// Reverse: higher index comes first
8787
return sb[i].Index > sb[j].Index
@@ -100,7 +100,7 @@ func (sb sortByPercent) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
100100
func (sb sortByPercent) Less(i, j int) bool {
101101
if sb[i].PercentDone() == sb[j].PercentDone() {
102102
// When percentages are equal, preserve insertion order (earlier timeStart first)
103-
return sb[i].timeStart.Before(sb[j].timeStart)
103+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
104104
}
105105
return sb[i].PercentDone() < sb[j].PercentDone()
106106
}
@@ -112,7 +112,7 @@ func (sb sortByPercentDsc) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
112112
func (sb sortByPercentDsc) Less(i, j int) bool {
113113
if sb[i].PercentDone() == sb[j].PercentDone() {
114114
// When percentages are equal, preserve insertion order (earlier timeStart first)
115-
return sb[i].timeStart.Before(sb[j].timeStart)
115+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
116116
}
117117
// Reverse: higher percentage comes first
118118
return sb[i].PercentDone() > sb[j].PercentDone()
@@ -123,23 +123,27 @@ type sortByValue []*Tracker
123123
func (sb sortByValue) Len() int { return len(sb) }
124124
func (sb sortByValue) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
125125
func (sb sortByValue) Less(i, j int) bool {
126-
if sb[i].value == sb[j].value {
127-
return sb[i].timeStart.Before(sb[j].timeStart)
126+
valueI := sb[i].Value()
127+
valueJ := sb[j].Value()
128+
if valueI == valueJ {
129+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
128130
}
129-
return sb[i].value < sb[j].value
131+
return valueI < valueJ
130132
}
131133

132134
type sortByValueDsc []*Tracker
133135

134136
func (sb sortByValueDsc) Len() int { return len(sb) }
135137
func (sb sortByValueDsc) Swap(i, j int) { sb[i], sb[j] = sb[j], sb[i] }
136138
func (sb sortByValueDsc) Less(i, j int) bool {
137-
if sb[i].value == sb[j].value {
139+
valueI := sb[i].Value()
140+
valueJ := sb[j].Value()
141+
if valueI == valueJ {
138142
// When values are equal, preserve insertion order (earlier timeStart first)
139-
return sb[i].timeStart.Before(sb[j].timeStart)
143+
return sb[i].timeStartValue().Before(sb[j].timeStartValue())
140144
}
141145
// Reverse: higher value comes first
142-
return sb[i].value > sb[j].value
146+
return valueI > valueJ
143147
}
144148

145149
type sortDsc struct{ sort.Interface }

0 commit comments

Comments
 (0)