aboutsummaryrefslogtreecommitdiff
path: root/internal/progress
diff options
context:
space:
mode:
Diffstat (limited to 'internal/progress')
-rw-r--r--internal/progress/meter.go133
-rw-r--r--internal/progress/meter_test.go43
-rw-r--r--internal/progress/render.go20
3 files changed, 131 insertions, 65 deletions
diff --git a/internal/progress/meter.go b/internal/progress/meter.go
index 0e4138de..9d4f1155 100644
--- a/internal/progress/meter.go
+++ b/internal/progress/meter.go
@@ -1,45 +1,54 @@
package progress
import (
+ "sync/atomic"
"time"
- "lindenii.org/go/lgo/intconv"
"lindenii.org/go/lgo/iowrap"
)
const (
- updateInterval = time.Second
+ renderInterval = 100 * time.Millisecond
+ forceInterval = time.Second
throughputInterval = 500 * time.Millisecond
)
// Meter renders one in-place progress line.
+//
+// Add is safe for concurrent use; a single background goroutine renders.
+// Stop must be called exactly once to flush the final line and release it.
type Meter struct {
writer iowrap.WriteFlusher
title string
- total uint64
+ total int
delay time.Duration
sparse bool
throughput bool
- startedAt time.Time
- nextUpdateAt time.Time
- nextThroughput time.Time
+ done atomic.Int64
+ bytes atomic.Int64
+ sawValue atomic.Bool
- lastDone uint64
- lastBytes uint64
- lastPercent int
- lastCounterW int
- sawValue bool
+ startedAt time.Time
+ stop chan struct{}
+ exited chan struct{}
+
+ // The following are owned by the render goroutine while it runs,
+ // then by Stop once exited is closed.
+ nextForceAt time.Time
+ nextThroughput time.Time
+ lastPercent int
+ lastCounterW int
throughputSuffix string
}
-// New creates one progress meter.
+// New creates one progress meter and starts its render goroutine.
func New(opts Options) *Meter {
now := time.Now()
- return &Meter{
+ meter := &Meter{
writer: opts.Writer,
title: opts.Title,
total: opts.Total,
@@ -47,10 +56,20 @@ func New(opts Options) *Meter {
sparse: opts.Sparse,
throughput: opts.Throughput,
startedAt: now,
- nextUpdateAt: now.Add(updateInterval),
+ stop: make(chan struct{}),
+ exited: make(chan struct{}),
+ nextForceAt: now.Add(forceInterval),
nextThroughput: now.Add(throughputInterval),
lastPercent: -1,
}
+
+ if meter.writer != nil {
+ go meter.loop()
+ } else {
+ close(meter.exited)
+ }
+
+ return meter
}
// Options configures one progress meter.
@@ -58,7 +77,7 @@ type Options struct {
Writer iowrap.WriteFlusher
Title string
- Total uint64
+ Total int
// Delay suppresses progress output until Delay has elapsed since Start.
Delay time.Duration
@@ -68,63 +87,71 @@ type Options struct {
Throughput bool
}
-// Set records current progress
-// and renders when percent changed or the 1s tick elapsed.
-func (meter *Meter) Set(done uint64, bytes uint64) {
- meter.lastDone = done
- meter.lastBytes = bytes
- meter.sawValue = true
+// Add increments the done and byte counters.
+//
+// Labels: MT-Safe.
+func (meter *Meter) Add(done, bytes int64) {
+ meter.done.Add(done)
+ meter.bytes.Add(bytes)
+ meter.sawValue.Store(true)
+}
- if meter.writer == nil {
+// Stop ends the render goroutine, forces the final line, and appends ", <msg>.".
+func (meter *Meter) Stop(msg string) {
+ close(meter.stop)
+ <-meter.exited
+
+ if !meter.sawValue.Load() || meter.writer == nil {
return
}
- now := time.Now()
- forced := meter.consumeUpdateTick(now)
+ if msg == "" {
+ msg = "done"
+ }
- percentChanged := false
+ if meter.sparse && meter.total > 0 && int(meter.done.Load()) != meter.total {
+ meter.done.Store(int64(meter.total))
+ }
- if meter.total > 0 {
- percent, err := intconv.Uint64ToInt(done * 100 / meter.total)
- if err != nil {
- return // TODO
- }
+ meter.render(time.Now(), ", "+msg+".\n")
+}
- percentChanged = percent != meter.lastPercent
- }
+func (meter *Meter) loop() {
+ defer close(meter.exited)
- if !percentChanged && !forced {
- return
- }
+ ticker := time.NewTicker(renderInterval)
+ defer ticker.Stop()
- meter.render(now, "\r")
+ for {
+ select {
+ case <-meter.stop:
+ return
+ case now := <-ticker.C:
+ meter.maybeRender(now)
+ }
+ }
}
-// Stop forces the final progress line and appends ", <msg>.".
-func (meter *Meter) Stop(msg string) {
- if !meter.sawValue || meter.writer == nil {
+func (meter *Meter) maybeRender(now time.Time) {
+ if !meter.sawValue.Load() {
return
}
- if msg == "" {
- msg = "done"
- }
+ forced := false
- if meter.sparse && meter.total > 0 && meter.lastDone != meter.total {
- meter.lastDone = meter.total
+ for !now.Before(meter.nextForceAt) {
+ meter.nextForceAt = meter.nextForceAt.Add(forceInterval)
+ forced = true
}
- meter.render(time.Now(), ", "+msg+".\n")
-}
+ percentChanged := false
-func (meter *Meter) consumeUpdateTick(now time.Time) bool {
- if now.Before(meter.nextUpdateAt) {
- return false
+ if meter.total > 0 {
+ percent := int(meter.done.Load() * 100 / int64(meter.total))
+ percentChanged = percent != meter.lastPercent
}
- for !now.Before(meter.nextUpdateAt) {
- meter.nextUpdateAt = meter.nextUpdateAt.Add(updateInterval)
+ if percentChanged || forced {
+ meter.render(now, "\r")
}
-
- return true
}
diff --git a/internal/progress/meter_test.go b/internal/progress/meter_test.go
new file mode 100644
index 00000000..8fa09973
--- /dev/null
+++ b/internal/progress/meter_test.go
@@ -0,0 +1,43 @@
+package progress_test
+
+import (
+ "bytes"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "lindenii.org/go/furgit/internal/progress"
+ "lindenii.org/go/lgo/iowrap"
+)
+
+func TestMeterConcurrentAdd(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+
+ meter := progress.New(progress.Options{
+ Writer: iowrap.NopFlush(&buf),
+ Title: "test",
+ Total: 1000,
+ })
+
+ var wg sync.WaitGroup
+
+ for range 10 {
+ wg.Go(func() {
+ for range 100 {
+ meter.Add(1, 0)
+ time.Sleep(time.Millisecond)
+ }
+ })
+ }
+
+ wg.Wait()
+
+ meter.Stop("done")
+
+ if got := buf.String(); !strings.Contains(got, "100% (1000/1000)") {
+ t.Fatalf("final line = %q, want it to contain %q", got, "100% (1000/1000)")
+ }
+}
diff --git a/internal/progress/render.go b/internal/progress/render.go
index f51852eb..a67d40ff 100644
--- a/internal/progress/render.go
+++ b/internal/progress/render.go
@@ -7,7 +7,6 @@ import (
"lindenii.org/go/furgit/internal/utils"
"lindenii.org/go/lgo/fmt/humanize"
- "lindenii.org/go/lgo/intconv"
)
func (meter *Meter) render(now time.Time, eol string) {
@@ -41,19 +40,15 @@ func (meter *Meter) render(now time.Time, eol string) {
}
func (meter *Meter) renderCounters() string {
- if meter.total > 0 {
- u, err := intconv.Uint64ToInt(meter.lastDone * 100 / meter.total)
- if err != nil {
- return "overflow"
- // TODO
- }
+ done := meter.done.Load()
- meter.lastPercent = u
+ if meter.total > 0 {
+ meter.lastPercent = int(done * 100 / int64(meter.total))
- return fmt.Sprintf("%3d%% (%d/%d)%s", meter.lastPercent, meter.lastDone, meter.total, meter.throughputSuffix)
+ return fmt.Sprintf("%3d%% (%d/%d)%s", meter.lastPercent, done, meter.total, meter.throughputSuffix)
}
- return fmt.Sprintf("%d%s", meter.lastDone, meter.throughputSuffix)
+ return fmt.Sprintf("%d%s", done, meter.throughputSuffix)
}
func (meter *Meter) refreshThroughput(now time.Time) {
@@ -74,6 +69,7 @@ func (meter *Meter) refreshThroughput(now time.Time) {
return
}
- rate := uint64(float64(meter.lastBytes) / elapsed.Seconds())
- meter.throughputSuffix = ", " + humanize.Bytes(meter.lastBytes) + " | " + humanize.Bytes(rate) + "/s"
+ bytes := meter.bytes.Load()
+ rate := uint64(float64(bytes) / elapsed.Seconds())
+ meter.throughputSuffix = ", " + humanize.Bytes(uint64(bytes)) + " | " + humanize.Bytes(rate) + "/s" //#nosec G115
}