Findings from a Rob-Pike-lens review (bugs/races/network), each verified against the code before fixing: - httpdl: name/out are now atomic.Pointer[string] -- the engine publishes a download to the reporter before Run resolves the name, so Stat raced the write (bt already did this) - httpdl: a malformed --proxy fails loudly instead of silently bypassing it - httpdl: a 206 must carry a matching Content-Range; a 200 in segmented mode is fatal so it fails over instead of burning the retry budget - httpdl: single-stream mirror failover validates the range before appending; ErrTooSlow only when the error is a real ctx cancellation - httpdl: idle guard tracks progress by timestamp (no Reset/Stop race, no sticky fired flag) - httpdl/control: reject a resume file whose segments don't tile [0,total) - bt: clamp the listen-port range; verify on-disk data before choosing pieces under --check-integrity - cli: reject size overflow; show --seed-time=MIN; clamp --select-file range - progress/engine/main: clamp ETA against int64 overflow; show queued downloads as waiting; join the reporter on exit instead of a 20ms sleep
435 lines
12 KiB
Go
435 lines
12 KiB
Go
// Package progress renders a live, single-line status display. One goroutine ticks
|
|
// once a second, pulls a snapshot of the running downloads, and derives speeds
|
|
// from the change since the previous tick. There are no locks here: the data
|
|
// arrives by value through the snapshot function.
|
|
package progress
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/hanbok/got/download"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// Reporter draws the progress line until its context is cancelled.
|
|
type Reporter struct {
|
|
snap func() []download.Stat
|
|
human bool
|
|
interval time.Duration
|
|
w io.Writer
|
|
tty bool
|
|
width int
|
|
height int
|
|
|
|
win map[string]*speedWindow // keyed by Stat.ID
|
|
prevLines int // lines drawn in the previous TTY frame, redrawn in place
|
|
lastLog time.Time // last non-TTY line emitted
|
|
}
|
|
|
|
// logEvery bounds how often progress is printed when output is not a TTY, so a
|
|
// redirected or piped run does not get a line every second.
|
|
const logEvery = 30 * time.Second
|
|
|
|
// windowTime is the span of the sliding speed window (10s): the rate is the
|
|
// byte delta across this window divided by its real duration, which smooths the
|
|
// per-tick jitter.
|
|
const windowTime = 10 * time.Second
|
|
|
|
// sample is one (time, cumulative-counters) observation in a speedWindow.
|
|
type sample struct {
|
|
t time.Time
|
|
completed, uploaded int64
|
|
}
|
|
|
|
// speedWindow keeps the last ~windowTime of samples for one download so we can
|
|
// derive down/up speed from the change across the window rather than the last
|
|
// tick alone. Samples are appended in time order, so stale ones live at the
|
|
// front.
|
|
type speedWindow struct {
|
|
samples []sample
|
|
}
|
|
|
|
// New returns a Reporter that reads live stats from snap.
|
|
func New(snap func() []download.Stat, human bool) *Reporter {
|
|
return &Reporter{
|
|
snap: snap,
|
|
human: human,
|
|
interval: time.Second,
|
|
w: os.Stdout,
|
|
tty: term.IsTerminal(int(os.Stdout.Fd())),
|
|
width: 80,
|
|
height: 24,
|
|
win: map[string]*speedWindow{},
|
|
}
|
|
}
|
|
|
|
// Run draws on every tick and clears the line when ctx is cancelled.
|
|
func (r *Reporter) Run(ctx context.Context) {
|
|
t := time.NewTicker(r.interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
r.render(true)
|
|
return
|
|
case <-t.C:
|
|
r.render(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) render(final bool) {
|
|
stats := r.snap()
|
|
now := time.Now()
|
|
|
|
// Record one sample per download before deriving rates, and forget windows
|
|
// for downloads that have dropped out of the snapshot.
|
|
seen := make(map[string]bool, len(stats))
|
|
for _, s := range stats {
|
|
seen[s.ID] = true
|
|
w := r.win[s.ID]
|
|
if w == nil {
|
|
w = &speedWindow{}
|
|
r.win[s.ID] = w
|
|
}
|
|
w.add(now, s.Completed, s.Uploaded)
|
|
}
|
|
for id := range r.win {
|
|
if !seen[id] {
|
|
delete(r.win, id)
|
|
}
|
|
}
|
|
|
|
if r.tty {
|
|
r.width = termWidth(r.width)
|
|
r.height = termHeight(r.height)
|
|
r.redraw(r.block(stats), final)
|
|
return
|
|
}
|
|
// Not a TTY: no cursor control, so emit a single line sparingly (and always
|
|
// the final one) to keep redirected output and logs readable.
|
|
var line string
|
|
switch {
|
|
case len(stats) == 1:
|
|
line = r.lineOne(stats[0])
|
|
case len(stats) > 1:
|
|
line = r.summary(stats)
|
|
}
|
|
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
|
|
fmt.Fprintln(r.w, line)
|
|
r.lastLog = now
|
|
}
|
|
}
|
|
|
|
func (r *Reporter) lineOne(s download.Stat) string {
|
|
dl, ul := r.rates(s)
|
|
seeding := s.Status == download.Seeding
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[%s ", shortName(s.Name))
|
|
// While seeding, report the share ratio in place of the size block and drop
|
|
// the DL: field; otherwise show completed/total (or bare completed).
|
|
if seeding {
|
|
fmt.Fprintf(&b, "SEED(%.1f)", ratio(s.Uploaded, s.Completed))
|
|
} else if s.Total > 0 {
|
|
fmt.Fprintf(&b, "%s/%s(%d%%)", humanSize(s.Completed, r.human), humanSize(s.Total, r.human), percent(s.Completed, s.Total))
|
|
} else {
|
|
b.WriteString(humanSize(s.Completed, r.human))
|
|
}
|
|
// CN is always shown (including HTTP); SD is shown for any torrent.
|
|
fmt.Fprintf(&b, " CN:%d", s.Conns)
|
|
if s.IsBT {
|
|
fmt.Fprintf(&b, " SD:%d", s.Seeders)
|
|
}
|
|
if !seeding {
|
|
fmt.Fprintf(&b, " DL:%s", speed(dl, r.human))
|
|
}
|
|
if seeding || ul > 0 {
|
|
fmt.Fprintf(&b, " UL:%s", speed(ul, r.human))
|
|
}
|
|
if !seeding && s.Total > 0 && dl > 0 {
|
|
fmt.Fprintf(&b, " ETA:%s", secfmt(etaDuration(s.Total-s.Completed, dl)))
|
|
}
|
|
b.WriteByte(']')
|
|
return b.String()
|
|
}
|
|
|
|
// nameW is the fixed rune width of the name column in the table, matching
|
|
// shortName's cap so a padded name lines the columns up.
|
|
const nameW = 20
|
|
|
|
// block builds the lines of the live display: the single-download dashboard for
|
|
// one download, a name/bar/speed/ETA table for several, or a one-line summary
|
|
// when the table would not fit the window.
|
|
func (r *Reporter) block(stats []download.Stat) []string {
|
|
switch {
|
|
case len(stats) == 0:
|
|
return nil
|
|
case len(stats) == 1:
|
|
return []string{r.lineOne(stats[0])}
|
|
case len(stats)+1 > r.height: // header + one row each would overflow the screen
|
|
return []string{r.summary(stats)}
|
|
default:
|
|
out := make([]string, 0, len(stats)+1)
|
|
out = append(out, r.summary(stats))
|
|
for _, s := range stats {
|
|
out = append(out, r.tableRow(s))
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
// redraw repaints the block in place: move to the top of the previous frame,
|
|
// clear it, and print the new lines clamped to the terminal width. A stray line
|
|
// from stderr that lands in the block is painted over on the next tick.
|
|
func (r *Reporter) redraw(lines []string, final bool) {
|
|
if r.prevLines > 0 {
|
|
if r.prevLines > 1 {
|
|
fmt.Fprintf(r.w, "\x1b[%dA", r.prevLines-1) // up to the first line
|
|
}
|
|
fmt.Fprint(r.w, "\r\x1b[J") // column 0, clear to end of screen
|
|
}
|
|
for i, ln := range lines {
|
|
if i > 0 {
|
|
fmt.Fprint(r.w, "\n")
|
|
}
|
|
fmt.Fprint(r.w, clampRunes(ln, r.width))
|
|
}
|
|
r.prevLines = len(lines)
|
|
if final && len(lines) > 0 {
|
|
fmt.Fprintln(r.w) // leave the cursor below the block for the OK/FAIL lines
|
|
r.prevLines = 0
|
|
}
|
|
}
|
|
|
|
// summary is the header/aggregate line: the active count and total down/up
|
|
// speed, plus a stalled count when any download has gone quiet.
|
|
func (r *Reporter) summary(stats []download.Stat) string {
|
|
if len(stats) == 0 {
|
|
return ""
|
|
}
|
|
var totDL, totUL int64
|
|
stalled, waiting := 0, 0
|
|
for _, s := range stats {
|
|
dl, ul := r.rates(s)
|
|
totDL += dl
|
|
totUL += ul
|
|
if s.Status == download.Waiting {
|
|
waiting++
|
|
continue
|
|
}
|
|
if r.isStalled(s, dl) {
|
|
stalled++
|
|
}
|
|
}
|
|
line := fmt.Sprintf("%d active DL:%s UL:%s", len(stats)-waiting, speed(totDL, r.human), speed(totUL, r.human))
|
|
if waiting > 0 {
|
|
line += fmt.Sprintf(" (%d waiting)", waiting)
|
|
}
|
|
if stalled > 0 {
|
|
line += fmt.Sprintf(" (%d stalled)", stalled)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// tableRow renders one download as "name |bar| pct metric tail": the bar and
|
|
// percent for progress, then the field that answers "stalled or fast" (DL speed
|
|
// or seeding) and a tail (ETA, upload rate, or a stalled flag).
|
|
func (r *Reporter) tableRow(s download.Stat) string {
|
|
dl, ul := r.rates(s)
|
|
metric, tail := r.rowMetric(s, dl, ul)
|
|
return fmt.Sprintf(" %s %s %s %-9s %s", padRune(shortName(s.Name), nameW), barOf(s), pctField(s), metric, tail)
|
|
}
|
|
|
|
// rowMetric returns a table row's two trailing fields for the download's state:
|
|
// the primary metric (DL speed, "seeding", "done", "failed") and a tail (ETA,
|
|
// upload rate, or a "stalled" flag).
|
|
func (r *Reporter) rowMetric(s download.Stat, dl, ul int64) (metric, tail string) {
|
|
switch s.Status {
|
|
case download.Waiting:
|
|
return "queued", ""
|
|
case download.Seeding:
|
|
return "seeding", "UL:" + speed(ul, r.human)
|
|
case download.Complete:
|
|
return "done", ""
|
|
case download.Errored:
|
|
return "failed", ""
|
|
default:
|
|
metric = "DL:" + speed(dl, r.human)
|
|
switch {
|
|
case dl > 0 && s.Total > 0:
|
|
tail = secfmt(etaDuration(s.Total-s.Completed, dl))
|
|
case r.isStalled(s, dl):
|
|
tail = "stalled"
|
|
}
|
|
return metric, tail
|
|
}
|
|
}
|
|
|
|
// maxETASeconds caps an ETA before it becomes a Duration: a near-stalled download
|
|
// yields an astronomically large seconds value, and time.Duration(secs) *
|
|
// time.Second would overflow int64 and wrap to a bogus tiny ETA. secfmt renders
|
|
// anything past 99h as "--", so clamping to 100h loses nothing.
|
|
const maxETASeconds = 100 * 3600
|
|
|
|
func etaDuration(remaining, dl int64) time.Duration {
|
|
secs := float64(remaining) / float64(dl)
|
|
if secs > maxETASeconds {
|
|
secs = maxETASeconds
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
|
|
// isStalled reports an active download that has gone a while with no new bytes,
|
|
// so the table can flag it rather than leave the eye to read DL:0B. A download
|
|
// too young to have a rate yet is not stalled.
|
|
func (r *Reporter) isStalled(s download.Stat, dl int64) bool {
|
|
if s.Status != download.Active || dl > 0 {
|
|
return false
|
|
}
|
|
w := r.win[s.ID]
|
|
if w == nil || len(w.samples) < 2 {
|
|
return false
|
|
}
|
|
return w.samples[len(w.samples)-1].t.Sub(w.samples[0].t) >= 4*time.Second
|
|
}
|
|
|
|
// barOf renders a fixed-width progress bar. A seeding or complete download is
|
|
// full; an unknown total (no Content-Length, or pre-metadata) shows empty.
|
|
func barOf(s download.Stat) string {
|
|
const cells = 12
|
|
k := 0
|
|
switch {
|
|
case s.Status == download.Seeding || s.Status == download.Complete:
|
|
k = cells
|
|
case s.Total > 0:
|
|
k = int(int64(cells) * s.Completed / s.Total)
|
|
if k > cells {
|
|
k = cells
|
|
}
|
|
}
|
|
return "|" + strings.Repeat("#", k) + strings.Repeat("-", cells-k) + "|"
|
|
}
|
|
|
|
// pctField is the percent column: a number, full for seeding/complete, or "--"
|
|
// when the total is unknown.
|
|
func pctField(s download.Stat) string {
|
|
switch {
|
|
case s.Status == download.Seeding || s.Status == download.Complete:
|
|
return "100%"
|
|
case s.Total > 0:
|
|
return fmt.Sprintf("%3d%%", percent(s.Completed, s.Total))
|
|
default:
|
|
return " --"
|
|
}
|
|
}
|
|
|
|
// padRune right-pads s with spaces to w runes (it is never longer, having been
|
|
// through shortName) so table columns line up on rune count.
|
|
func padRune(s string, w int) string {
|
|
if n := utf8.RuneCountInString(s); n < w {
|
|
return s + strings.Repeat(" ", w-n)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// add records a sample and drops any that have fallen out of the window. A
|
|
// sample is only appended once per second, but the latest counters are folded
|
|
// into the newest slot so the final cancel-render, which typically fires in the
|
|
// same second as the preceding tick, still sees current totals.
|
|
func (w *speedWindow) add(now time.Time, completed, uploaded int64) {
|
|
// Drop samples older than the window, keeping the first one that still falls
|
|
// inside it as the baseline for the delta.
|
|
cut := now.Add(-windowTime)
|
|
i := 0
|
|
for i < len(w.samples) && w.samples[i].t.Before(cut) {
|
|
i++
|
|
}
|
|
w.samples = w.samples[i:]
|
|
|
|
if n := len(w.samples); n > 0 && now.Sub(w.samples[n-1].t) < time.Second {
|
|
w.samples[n-1].completed = completed
|
|
w.samples[n-1].uploaded = uploaded
|
|
return
|
|
}
|
|
w.samples = append(w.samples, sample{now, completed, uploaded})
|
|
}
|
|
|
|
// rates derives down/up speed in bytes/s from the change across the speed
|
|
// window (windowBytes / windowSeconds). With a single sample we have no span
|
|
// yet, so we report 0 rather than mistaking the cumulative bytes (e.g. resumed
|
|
// data) for one window's worth.
|
|
func (r *Reporter) rates(s download.Stat) (dl, ul int64) {
|
|
w := r.win[s.ID]
|
|
if w == nil || len(w.samples) < 2 {
|
|
return 0, 0
|
|
}
|
|
first, last := w.samples[0], w.samples[len(w.samples)-1]
|
|
secs := last.t.Sub(first.t).Seconds()
|
|
if secs <= 0 {
|
|
return 0, 0
|
|
}
|
|
dl = int64(float64(last.completed-first.completed) / secs)
|
|
ul = int64(float64(last.uploaded-first.uploaded) / secs)
|
|
if dl < 0 {
|
|
dl = 0
|
|
}
|
|
if ul < 0 {
|
|
ul = 0
|
|
}
|
|
return dl, ul
|
|
}
|
|
|
|
// ratio is uploaded/completed for the seeding readout; 0 when nothing has been
|
|
// downloaded yet to avoid dividing by zero.
|
|
func ratio(uploaded, completed int64) float64 {
|
|
if completed <= 0 {
|
|
return 0
|
|
}
|
|
return float64(uploaded) / float64(completed)
|
|
}
|
|
|
|
func shortName(s string) string {
|
|
const max = 20
|
|
// Truncate on runes, not bytes, so a multibyte name (CJK, emoji) is never
|
|
// cut mid-rune.
|
|
if utf8.RuneCountInString(s) > max {
|
|
return string([]rune(s)[:max-1]) + "~"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// clampRunes limits a line to width runes (not bytes) so multibyte glyphs are
|
|
// never split when the terminal is narrow. A non-positive width leaves it whole.
|
|
func clampRunes(s string, width int) string {
|
|
if width <= 0 || utf8.RuneCountInString(s) <= width {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:width])
|
|
}
|
|
|
|
func termWidth(prev int) int {
|
|
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
|
|
return w
|
|
}
|
|
if prev > 0 {
|
|
return prev
|
|
}
|
|
return 80
|
|
}
|
|
|
|
func termHeight(prev int) int {
|
|
if _, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil && h > 0 {
|
|
return h
|
|
}
|
|
if prev > 0 {
|
|
return prev
|
|
}
|
|
return 24
|
|
}
|