1 Commits
Author SHA1 Message Date
sneak 52cafd1cd3 Show the progress rate over the last 5 seconds, and what a phase is doing after 10 seconds with no item done (closes #125)
check / check (push) Canceled after 0s
The rate was the phase's average, so a burst after a long wait read as
over 1,000 files a second. It now covers the items completed over the
last 5 seconds, or since the line before off a terminal; the ETA still
comes from the average. Once no item has completed for 10 seconds, the
rate gives way to "stalled" and what the phase is doing, as "stalled
reading", kept whole with the phase, progress, elapsed time and ETA, so
only the bar gives way before them. report's read phase says
"read/write", as it writes the report while it reads. The tests set the
display's clock by hand.

Deviation: the stall words hold no time; with one, no words naming the
work fit beside the rest at 80 columns.
Judgement call: trees' group phase says "grouping".

Model: opus-5-5
2026-10-16 21:40:05 +00:00
7 changed files with 146 additions and 102 deletions
+24 -21
View File
@@ -836,9 +836,8 @@ Each display is one line holding:
directories or groups. It covers the items completed over the last 5 seconds,
or, when stderr is not a terminal, since the line before, so it falls to 0
while nothing completes, and a burst of items after a wait shows as a burst
rather than as a steady rate.
- once no item has completed for 10 seconds, how long that has been and what the
phase is doing (see below)
rather than as a steady rate. Once no item has completed for 10 seconds, the
word `stalled` and what the phase is doing take the rate's place (see below).
- elapsed time
- estimated time remaining, or `unknown`. It comes from the phase's average rate
since it started counting, so that it does not swing with each burst.
@@ -848,17 +847,19 @@ Example shape (exact layout is flexible, content is not):
```
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
[3/5] hash: [12345/98765] 12%, 0 files/s, no files finished for 42s, reading files, elapsed 4m1s, eta 28m7s
[3/5] hash: [12345/45678] 27%, stalled reading, elapsed 4m30s, eta 12m9s
```
When no item has completed for 10 seconds, as while one large file is read, a
disk is slow or the database is busy, the line says so and what the phase is
doing: `walk` is listing directories; `hash` and the content phase's reads are
reading files; `update` is writing to the database; `load` and the `read` phases
of `trees` and `report` are reading the database; the content phase's checks are
searching the database; `trees`' `group` is grouping directories, and its
`write` is writing the report. A phase still waiting for its total already says
what it waits for, and says nothing more.
disk is slow or the database is busy, the line says `stalled` and what the phase
is doing in place of the rate, which would read 0: `walk` is `listing`
directories; `hash` and the content phase's reads are `reading` files; `update`
is `writing` to the database; `load` and the `read` phase of `trees` are
`reading` the database; the content phase's checks are `searching` the database;
`trees`' `group` is `grouping` directories, and its `write` is `writing` the
report. The `read` phase of `report` writes the report while it reads the
records and may be waiting on either, so it says `read/write`. A phase still
waiting for its total already says what it waits for, and says nothing more.
Additional requirements:
@@ -871,16 +872,18 @@ Additional requirements:
times a second, so its elapsed time keeps moving while a phase waits for its
next item.
- On a terminal no line reaches the terminal's last column, however narrow the
terminal is. The phase and its place, the progress, the elapsed time and the
ETA are kept whole: a line too wide for the terminal leaves out its rate first
(or what the phase is waiting for), then what it says of 10 seconds with no
item completed, and a bar with no room beside those four gives way to the
spinner. Only when those four alone are too wide, as on a terminal narrower
than about 70 columns hours into a scan of millions of files, is the line cut
short at the terminal's edge, so the end of the ETA is lost first. A terminal
made narrower while a phase runs gets the same: the next draw erases the line
within the new width and fits the line to it, and the display goes on
redrawing in place.
terminal is. The phase and its place, the progress, what a stalled phase is
doing, the elapsed time and the ETA are kept whole: a line too wide for the
terminal leaves out its rate (or what the phase is waiting for), and a bar
with no room beside the parts kept whole gives way to the spinner. The words a
stalled phase shows are short, so that a line like the stalled one in the
example above, with tens of thousands of files and times in minutes, fits on
an 80-column terminal. Only when the parts kept whole are too wide, as on a
terminal narrower than about 70 columns hours into a scan of millions of
files, or about 90 once the phase has stalled, is the line cut short at the
terminal's edge, so the end of the ETA is lost first. A terminal made narrower
while a phase runs gets the same: the next draw erases the line within the new
width and fits the line to it, and the display goes on redrawing in place.
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
redraws: print the line the moment each phase starts, every 5 seconds while it
runs (whether or not an item has completed, and never more often), and once
+7 -5
View File
@@ -33,11 +33,13 @@
seconds, or since the line before when stderr is not a terminal, so it falls
to 0 while nothing completes, and a burst after a wait no longer reads as a
steady rate of over 1,000 files a second; the ETA still comes from the phase's
average. Once no item has completed for 10 seconds, the line says how long and
what the phase is doing, as `no files finished for 42s, reading files`, each
phase naming its own work where it is defined. A line too wide for the
terminal leaves that out after the rate, keeping the phase, progress, elapsed
time and ETA whole (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/125)
average. Once no item has completed for 10 seconds, the word `stalled` and
what the phase is doing take the rate's place, as `stalled reading`, each
phase naming its own work where it is defined; `report`'s `read` says
`read/write`, as it writes the report while it reads. Those words are kept
whole with the phase, progress, elapsed time and ETA, so only the bar gives
way before them, and they fit on an 80-column terminal in an ordinary scan
(2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/125)
- new subcommand `info` prints the database's details on stdout, one per line as
a name, a tab and the value: its path and where it came from, the sizes of the
+27 -30
View File
@@ -28,7 +28,7 @@ const redrawInterval = 100 * time.Millisecond
const rateWindow = 5 * time.Second
// stallTime is how long a phase goes with no item completed before its
// line says so, and what the phase is doing.
// line says it has stalled, and what it is doing, in place of the rate.
const stallTime = 10 * time.Second
// spinnerType selects the progressbar library's spinner style.
@@ -68,9 +68,9 @@ const (
openingDatabase = "opening the database"
)
// readingDatabase is what the display of a phase that reads the records
// says it is doing once no record has been read for stallTime.
const readingDatabase = "reading the database"
// reading is what the display of a phase that reads files or records
// says it is doing once it has stalled; see stallTime.
const reading = "reading"
// stderrIsTTY reports whether stderr is attached to a terminal.
func stderrIsTTY() bool {
@@ -102,7 +102,7 @@ type progressParams struct {
total int64 // the items the phase will complete; -1 when not known
estimate bool // total is an estimate, which the count may pass
waitingFor string // what the phase waits for until setTotal is called
doing string // what the phase is doing, as "reading files"; see stallTime
doing string // what the phase is doing, as "reading"; see stallTime
}
// drawnCount is the count of items completed as of one draw, and when
@@ -448,24 +448,23 @@ func (p *progress) finish() {
// with no total shows the items done instead, or what it waits for. The
// rate is that since the first draw in drawn, and the ETA comes from
// the rate since the count started. Once no item has been completed for
// stallTime, the line says so before the elapsed time, and what the
// phase is doing, as "no files finished for 42s, reading files".
// stallTime, the rate, which would read 0, gives way to the word
// "stalled" and what the phase is doing, as "stalled reading".
//
// The phase and its place, the progress, the elapsed time and the ETA
// are kept whole. A line wider than room leaves out the rate, or what
// the phase waits for, then what it says of a stall; only if it is
// still wider is it cut at room, and line reports that it does not fit.
// Every part is ASCII, so its length is its width in columns. p.mu must
// be held.
// The phase and its place, the progress, what a stalled phase is doing,
// the elapsed time and the ETA are kept whole. A line wider than room
// leaves out the rate, or what the phase waits for; only if it is still
// wider is it cut at room, and line reports that it does not fit. Every
// part is ASCII, so its length is its width in columns. p.mu must be
// held.
func (p *progress) line(now time.Time, room int,
percent bool,
) (string, bool) {
elapsed := now.Sub(p.start).Round(time.Second)
// head and tail hold the parts kept whole. extra, after head, is the
// part left out first, and stall, before tail, the part left out
// next.
var head, extra, stall, tail string
// head and tail hold the parts kept whole; extra, between them, is
// the part left out.
var head, extra, tail string
if p.total < 0 && p.waitingFor != "" {
head, extra = p.label+": ", p.waitingFor+", "
@@ -477,24 +476,22 @@ func (p *progress) line(now time.Time, room int,
head = p.label + ": " + p.itemsDone(percent) + ", "
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
if idle := now.Sub(p.lastItem); idle >= stallTime {
stall = fmt.Sprintf("no %s finished for %s, %s, ",
p.unit, idle.Round(time.Second), p.doing)
}
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(average))
}
for _, text := range []string{
head + extra + stall + tail, head + stall + tail, head + tail,
} {
if len(text) <= room {
return text, true
if now.Sub(p.lastItem) >= stallTime {
head += "stalled " + p.doing + ", "
extra = ""
}
}
kept := head + tail
full, kept := head+extra+tail, head+tail
switch {
case len(full) <= room:
return full, true
case len(kept) <= room:
return kept, true
}
return kept[:max(room, 0)], false
}
+77 -28
View File
@@ -167,9 +167,9 @@ func TestProgressLine(t *testing.T) {
// TestLineFitsRoom checks how a line is fitted to the room it has, ten
// seconds into the phase, and fifty into a phase with no file read for
// the last 42: it leaves out the rate, or what the phase waits for,
// then what it says of the stall, and is cut only when what is left is
// still too wide.
// the last 42: it leaves out the rate, or what the phase waits for, but
// not what a stalled phase is doing, and is cut only when what is left
// is still too wide.
func TestLineFitsRoom(t *testing.T) {
t.Parallel()
@@ -182,13 +182,13 @@ func TestLineFitsRoom(t *testing.T) {
}
content := &progress{
label: scanPhase("content"), total: -1,
waitingFor: searchingDatabase, start: ago, rateStart: ago,
waitingFor: "searching the database", start: ago, rateStart: ago,
}
started := now.Add(-50 * time.Second)
stalled := &progress{
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
doing: readingFiles, start: started, rateStart: started,
doing: reading, start: started, rateStart: started,
lastItem: now.Add(-42 * time.Second),
drawn: []drawnCount{{at: now.Add(-rateWindow), count: 20}},
}
@@ -221,19 +221,15 @@ func TestLineFitsRoom(t *testing.T) {
want: "[5/5] content: elapsed 10s, eta unknown",
fits: true,
}, {
p: stalled, room: 101, percent: true,
want: "[3/5] hash: [20/100] 20%, 0 files/s, no files finished for " +
"42s, reading files, elapsed 50s, eta 3m20s",
p: stalled, room: 65, percent: true,
want: "[3/5] hash: [20/100] 20%, stalled reading, elapsed 50s, " +
"eta 3m20s",
fits: true,
}, {
p: stalled, room: 100, percent: true,
want: "[3/5] hash: [20/100] 20%, no files finished for 42s, " +
"reading files, elapsed 50s, eta 3m20s",
fits: true,
}, {
p: stalled, room: 89, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s",
fits: true,
p: stalled, room: 64, percent: true,
want: "[3/5] hash: [20/100] 20%, stalled reading, elapsed 50s, " +
"eta 3m20",
fits: false,
}}
for _, tc := range cases {
@@ -255,7 +251,7 @@ func TestSetTotalStartsRate(t *testing.T) {
waited := clock.Now().Add(-20 * time.Second)
p := &progress{
now: clock.Now, label: scanPhase("load"), unit: scanUnit, total: -1,
doing: readingDatabase, start: waited, rateStart: waited,
doing: reading, start: waited, rateStart: waited,
lastItem: waited, drawn: []drawnCount{{at: waited}},
}
@@ -264,8 +260,8 @@ func TestSetTotalStartsRate(t *testing.T) {
// Ten seconds after the total came, no record has been read yet.
clock.Add(10 * time.Second)
want := "[1/5] load: [0/100] 0%, 0 files/s, no files finished for 10s, " +
"reading the database, elapsed 30s, eta unknown"
want := "[1/5] load: [0/100] 0%, stalled reading, elapsed 30s, " +
"eta unknown"
if got, _ := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want)
}
@@ -347,8 +343,8 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
// coming, as when one large file is being read or a disk is slow, goes
// on printing its line every interval: the rate falls to 0 once no file
// has been read for rateWindow, and once none has for stallTime the
// line says so, and what the phase is doing. The test moves the
// display's clock by hand.
// line says the phase has stalled, and what it is doing, in place of
// the rate. The test moves the display's clock by hand.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainProgressStalledProducer(t *testing.T) {
@@ -356,7 +352,7 @@ func TestPlainProgressStalledProducer(t *testing.T) {
clock := &testClock{now: time.Now()}
p := startProgress(progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 100,
doing: readingFiles,
doing: reading,
}, nil, testRedraw, clock.Now)
// Pass or fail, the display ends before stderr is put back.
@@ -377,8 +373,8 @@ func TestPlainProgressStalledProducer(t *testing.T) {
clock.Add(4 * time.Second)
awaitOutput(t, stderr,
"[3/5] hash: [20/100] 20%, 0 files/s, no files finished for 10s, "+
"reading files, elapsed 10s, eta 40s\n", 3)
"[3/5] hash: [20/100] 20%, stalled reading, elapsed 10s, eta 40s\n",
3)
}
// TestPlainProgressRateAfterStall draws a phase's plain line by hand, a
@@ -395,7 +391,7 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
// No timed draws: the test draws.
p := startProgress(progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 1000,
doing: readingFiles,
doing: reading,
}, nil, time.Hour, clock.Now)
late := plainInterval + time.Millisecond
@@ -415,8 +411,7 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
want := []string{
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown",
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown",
"[3/5] hash: [0/1000] 0%, 0 files/s, no files finished for 10s, " +
"reading files, elapsed 10s, eta unknown",
"[3/5] hash: [0/1000] 0%, stalled reading, elapsed 10s, eta unknown",
"[3/5] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s",
}
@@ -441,7 +436,7 @@ func TestProgressRateAfterStallRedrawn(t *testing.T) {
// No timed draws: the test draws.
p := startProgress(progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 1000,
doing: readingFiles,
doing: reading,
}, nil, time.Hour, clock.Now)
// redraw draws the display every redrawInterval for d.
@@ -604,6 +599,60 @@ func TestTerminalLinesKeepETA(t *testing.T) {
}
}
// TestTerminalStalledLineKeepsWords draws a stalled phase on an
// 80-column terminal at an ordinary scan's counts and times, 12345 of
// 45678 files read four and a half minutes in, the last 42 seconds ago,
// and report's read phase at the same counts: the bar gives way, and
// what the phase is doing shows whole in place of the rate, beside the
// phase, progress, elapsed time and ETA.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalStalledLineKeepsWords(t *testing.T) {
stderr := captureStderr(t)
cases := []struct {
params progressParams
want string
}{{
params: progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 45678,
doing: reading,
},
want: "[3/5] hash: [12345/45678] 27%, stalled reading, " +
"elapsed 4m30s, eta 12m9s",
}, {
params: progressParams{
label: reportPhase("read"), total: 45678, doing: "read/write",
},
want: "[2/2] read: [12345/45678] 27%, stalled read/write, " +
"elapsed 4m30s, eta 12m9s",
}}
for _, tc := range cases {
before := len(stderr())
clock := &testClock{now: time.Now()}
// No timed draws: the display is drawn as it starts and as it
// ends.
p := startProgress(tc.params, func() int { return 80 }, time.Hour,
clock.Now)
clock.Add(4*time.Minute + 30*time.Second - 42*time.Second)
p.mu.Lock()
p.count, p.lastItem = 12345, clock.Now()
p.mu.Unlock()
clock.Add(42 * time.Second)
p.finish()
shown := lastFrame(stderr()[before:])
if !strings.Contains(shown, tc.want) {
t.Errorf("terminal shows %q, want %q", shown, tc.want)
}
}
}
// TestTerminalLinesFitWidth draws a large scan's bar display and a small
// scan's, two and a half hours into the phase, on terminals from 120
// columns down to 40, and checks that every line stays clear of the
+3 -1
View File
@@ -74,9 +74,11 @@ func runReport(ctx context.Context, stdout io.Writer) error {
defer func() { _ = db.Close() }()
// The read phase writes the report while it reads the records, so a
// stall may be in either.
prog = newProgress(progressParams{
label: reportPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, doing: readingDatabase,
waitingFor: countingRecords, doing: "read/write",
})
report, err := readReport(ctx, db, dbPath, stdout, prog)
+5 -14
View File
@@ -88,15 +88,6 @@ type fileMeta struct {
// names it.
const scanUnit = "files"
// readingFiles is what the display of a phase that reads files says it
// is doing once no file has been read for stallTime.
const readingFiles = "reading files"
// searchingDatabase is what the display of the content phase's checks
// says it waits for until the search returns its first record, and what
// it says it is doing once no record has been checked for stallTime.
const searchingDatabase = "searching the database"
// scanPhase names one phase of a scan with its place among all of them,
// as "[2/5] walk", for the phase's progress display. The list here is
// the one place the phases are counted.
@@ -455,7 +446,7 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// first gives the display its total.
prog := newProgress(progressParams{
label: scanPhase("load"), unit: scanUnit, total: -1,
waitingFor: countingRecords, doing: readingDatabase,
waitingFor: countingRecords, doing: reading,
})
defer prog.finish()
@@ -506,7 +497,7 @@ func (s *scanState) walkPhase(
prog := newProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: estimate,
estimate: true, doing: "listing directories",
estimate: true, doing: "listing",
})
var (
@@ -698,7 +689,7 @@ func (s *scanState) readRuns(ctx context.Context, workers int,
prog := newProgress(progressParams{
label: scanPhase(label), unit: scanUnit, total: int64(len(runs)),
doing: readingFiles,
doing: reading,
})
defer prog.finish()
@@ -788,7 +779,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
total := len(s.batch) + len(s.sentinels) + len(deletes)
prog := newProgress(progressParams{
label: scanPhase("update"), unit: scanUnit, total: int64(total),
doing: "writing to the database",
doing: "writing",
})
defer prog.finish()
@@ -877,7 +868,7 @@ func (s *scanState) contentCandidates(
// the search returns, and so do their rate and ETA.
prog := newProgress(progressParams{
label: scanPhase("content"), unit: scanUnit, total: -1,
waitingFor: searchingDatabase, doing: searchingDatabase,
waitingFor: "searching the database", doing: "searching",
})
defer prog.finish()
+3 -3
View File
@@ -72,7 +72,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
prog = newProgress(progressParams{
label: treesPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, doing: readingDatabase,
waitingFor: countingRecords, doing: reading,
})
records, super, allDirs, err := readTree(ctx, db, prog)
@@ -84,7 +84,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
prog = newProgress(progressParams{
label: treesPhase("group"), unit: "directories",
total: int64(len(allDirs)), doing: "grouping directories",
total: int64(len(allDirs)), doing: "grouping",
})
dupes := collectTreeGroups(allDirs, super, prog)
@@ -92,7 +92,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
prog = newProgress(progressParams{
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
doing: "writing the report",
doing: "writing",
})
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog)