Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,7 @@ jobs:
# in somebody else's file.
run: |
set -euo pipefail
watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go internal/tool/tool.go internal/gui/window/tools.go go.mod .github/workflows/ci.yml .github/build-tags'
watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/gui/run_cgo.go internal/gui/window/tidy.go internal/audit/parallel.go internal/engine/parallel.go internal/tool/tool.go internal/gui/window/tools.go internal/format/video/ahead.go go.mod .github/workflows/ci.yml .github/build-tags'
# On a pull request there is no "before" - the field belongs to a push
# - so this asked for something empty and every pull request answered
# "touched". That quietly undid the decision of 2026-08-20, because
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ because it turns other people's test suites red.
reads the time the picture starts, the way a player's position does -
`00:00:07`, with milliseconds when the changes do not fall on whole
seconds. Each change is a whole new picture in bytes and in coding time,
and the frames between changes cost a few bytes each, so an hour at thirty
and when a film has more than one picture they are coded on several cores
at once - a thirty-minute 1920x1080 film took 68 s on sixteen threads, and
the
progress bar counts the pictures as well as the bytes, so the time it says
is left holds while they are coded. The frames
between changes cost a few bytes each, so an hour at thirty
frames a second with a change every second fits in about a megabyte
(1 075 831 B at the smallest), and a `change_interval` as long as the film
or longer keeps one picture throughout (977 434 B for that hour). Set
Expand Down
10 changes: 4 additions & 6 deletions internal/cli/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (p *progressBar) report(pr engine.Progress) {
line := fmt.Sprintf(" %d/%d files %s of %s %d%%%s",
pr.FilesDone, pr.FilesTotal,
core.HumanBytes(pr.BytesDone), core.HumanBytes(pr.BytesTotal),
core.Percent(pr.BytesDone, pr.BytesTotal),
pr.Percent(),
p.remaining(pr))

// Pad to cover whatever the last line left behind, so a shorter line does
Expand All @@ -82,14 +82,12 @@ func (p *progressBar) clear() {
}

// remaining is an estimate and says so by staying quiet until it has enough to
// go on. A number that swings wildly for the first second is worse than none.
// go on (engine.Progress.Left, which the window asks too).
func (p *progressBar) remaining(pr engine.Progress) string {
elapsed := time.Since(p.started)
if elapsed < time.Second || pr.BytesDone <= 0 || pr.BytesDone >= pr.BytesTotal {
left, ok := pr.Left(time.Since(p.started))
if !ok {
return ""
}
left := time.Duration(float64(elapsed) *
float64(pr.BytesTotal-pr.BytesDone) / float64(pr.BytesDone))
return " " + core.Roughly(left) + " left"
}

Expand Down
19 changes: 0 additions & 19 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,25 +451,6 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF
return pl.out, nil
}

// TotalBytes is what a plan will occupy on disk. Known before the first byte
// is written, which is what the free space guard and --dry-run stand on.
func TotalBytes(files []PlannedFile) int64 {
var n int64
for _, f := range files {
n += f.Plan.Bytes
}
return n
}

// Progress is how far a run has got. Both counts are known from the plan, so
// the fractions are exact rather than estimated.
type Progress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
}

// Run writes a planned set of files.
//
// Each file is written under a temporary name and only then renamed, so the
Expand Down
64 changes: 48 additions & 16 deletions internal/engine/parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
)

// This file is the only place in internal/engine that runs anything beside
Expand Down Expand Up @@ -157,7 +158,7 @@ func drain(ctx context.Context, next *atomic.Int64, files []PlannedFile, outDir
p := fileProgress{gate: gate}
sum, err := writeOne(ctx, files[i], outDir, &p)
out[i] = fileResult{sha: sum, ok: err == nil, err: err}
p.finished(files[i].Plan.Bytes, err == nil)
p.finished(files[i].Plan, err == nil)
}
}

Expand All @@ -182,12 +183,17 @@ func drain(ctx context.Context, next *atomic.Int64, files []PlannedFile, outDir
//
// A nil gate is a run nobody is watching. Every method takes a nil receiver,
// so a run without progress does no locking and allocates nothing for it.
//
// Work moves with the bytes, and on its own where a generator reports work its
// bytes do not show (format.Plan.Work) - the pictures of a film.
type progressGate struct {
mu sync.Mutex
filesDone int
bytesDone int64
workDone int64
filesTotal int
bytesTotal int64
workTotal int64
report func(Progress)
}

Expand All @@ -198,6 +204,7 @@ func newProgressGate(files []PlannedFile, report func(Progress)) *progressGate {
return &progressGate{
filesTotal: len(files),
bytesTotal: TotalBytes(files),
workTotal: totalWork(files),
report: report,
}
}
Expand All @@ -209,25 +216,27 @@ func newProgressGate(files []PlannedFile, report func(Progress)) *progressGate {
// file" describes no run at all. At one writer the numbers this produces are
// the same ones the sequential loop produced, which is what lets the guards
// that watch the bar stay as they were.
func (g *progressGate) advance(delta int64) {
func (g *progressGate) advance(bytes, work int64) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.bytesDone += delta
g.bytesDone += bytes
g.workDone += work
g.say()
}

// finished is the end of one file: the delta that squares this writer's
// reporting with what the plan promised, and one more file done.
func (g *progressGate) finished(delta int64) {
func (g *progressGate) finished(bytes, work int64) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
g.bytesDone += delta
g.bytesDone += bytes
g.workDone += work
g.filesDone++
g.say()
}
Expand All @@ -237,6 +246,7 @@ func (g *progressGate) say() {
g.report(Progress{
FilesDone: g.filesDone, FilesTotal: g.filesTotal,
BytesDone: g.bytesDone, BytesTotal: g.bytesTotal,
WorkDone: g.workDone, WorkTotal: g.workTotal,
})
}

Expand All @@ -248,14 +258,38 @@ func (g *progressGate) say() {
// delta, so the gate never has to know which file it came from.
type fileProgress struct {
gate *progressGate
reported int64
reported int64 // bytes
worked int64 // work, the bytes among it
}

// advance is the counting writer's callback: n is the running total for this
// file, and the gate is told the difference.
// file, and the gate is told the difference - as bytes, and as work, because
// writing a byte is a byte of work.
func (p *fileProgress) advance(n int64) {
p.gate.advance(n - p.reported)
d := n - p.reported
p.gate.advance(d, d)
p.reported = n
p.worked += d
}

// listen has this writer hear the file being written: its bytes through the
// counter, and the work its bytes do not show through the context the
// generator writes under (format.Worked). Nothing is set when nobody is
// listening, so a run without progress does no locking at all and allocates
// nothing for it.
func (p *fileProgress) listen(ctx context.Context, counter *countingWriter) context.Context {
if p.gate == nil {
return ctx
}
counter.report = p.advance
return format.WithWork(ctx, p.work)
}

// work is the generator's report of work its bytes do not show
// (format.Worked), n more of what its plan counted in Work.
func (p *fileProgress) work(n int64) {
p.gate.advance(0, n)
p.worked += n
}

// finished squares this file up. A file that succeeded is topped up to exactly
Expand All @@ -265,12 +299,14 @@ func (p *fileProgress) advance(n int64) {
// find. That the total can go backwards on a failed file is how it already
// behaves. What is new is only that another writer's bytes may sit in the same
// total while it happens.
func (p *fileProgress) finished(planned int64, ok bool) {
//
// Work is squared up the same way, to the plan's bytes and Work together.
func (p *fileProgress) finished(plan format.Plan, ok bool) {
if ok {
p.gate.finished(planned - p.reported)
p.gate.finished(plan.Bytes-p.reported, plan.Bytes+plan.Work-p.worked)
return
}
p.gate.finished(-p.reported)
p.gate.finished(-p.reported, -p.worked)
}

// writeOne writes one file under a temporary name and renames it only once it
Expand Down Expand Up @@ -334,11 +370,7 @@ func writeOne(ctx context.Context, f PlannedFile, outDir string, p *fileProgress
h := sha256.New()
buffered := bufio.NewWriterSize(fh, 64<<10)
counter := &countingWriter{w: io.MultiWriter(buffered, h)}
// Left nil when nobody is listening, so a run without progress does no
// locking at all and allocates nothing for it.
if p.gate != nil {
counter.report = p.advance
}
ctx = p.listen(ctx, counter)

// Damage sits between the generator and the counter, and the order is the
// design rather than a convenience.
Expand Down
70 changes: 70 additions & 0 deletions internal/engine/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Part of package engine. See engine.go.
package engine

import (
"time"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
)

// How far a run has got, and what it is measured against. Moved out of
// engine.go on 2026-10-06, when progress began to count the work a file costs
// as well as its bytes and that file passed its ceiling on length - the ceiling
// is a ratchet, so the answer is a split by subject. The gate that hands the
// numbers out while files are written in parallel stays in parallel.go,
// because it holds a lock and that file is where everything concurrent in this
// package lives.

// TotalBytes is what a plan will occupy on disk. Known before the first byte
// is written, which is what the free space guard and --dry-run stand on.
func TotalBytes(files []PlannedFile) int64 {
var n int64
for _, f := range files {
n += f.Plan.Bytes
}
return n
}

// totalWork is what the planned files cost to make, their bytes included.
func totalWork(files []PlannedFile) int64 {
var n int64
for _, f := range files {
n += f.Plan.Bytes + f.Plan.Work
}
return n
}

// Progress is how far a run has got. Both counts are known from the plan, so
// the fractions are exact rather than estimated.
//
// A fraction of the run and the time it has left come from the work, not the
// bytes. The work is the bytes plus what the plans counted beyond them
// (format.Plan.Work), so for a run of files whose cost is their bytes the two
// are the same numbers, and for a film they are the pictures as well as the
// padding - the bytes alone stood at five percent while a film's pictures were
// coded and promised hours for a minute's work.
type Progress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
WorkDone int64
WorkTotal int64
}

// Percent is how far the run has got, out of a hundred, by the work. The
// window's bar and the command line's both draw this, so the two cannot
// disagree and neither can fall back to the bytes on its own.
func (p Progress) Percent() int { return core.Percent(p.WorkDone, p.WorkTotal) }

// Left is how long the run has left at the pace it has kept so far, by the
// work, and false while there is not enough to go on: in the first second,
// before any work is done and once all of it is. A number that swings wildly
// for the first second is worse than no number. The two surfaces carried a
// copy of this each until 2026-10-06.
func (p Progress) Left(elapsed time.Duration) (time.Duration, bool) {
if elapsed < time.Second || p.WorkDone <= 0 || p.WorkDone >= p.WorkTotal {
return 0, false
}
return time.Duration(float64(elapsed) * float64(p.WorkTotal-p.WorkDone) / float64(p.WorkDone)), true
}
4 changes: 4 additions & 0 deletions internal/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,10 @@ type Plan struct {
// Memo is the generator's own scratch space, carried from planning to
// writing. Nothing outside the generator reads it.
Memo any
// Work is what writing the file costs beyond writing its bytes, counted
// as the bytes that would take as long to write - nought for every file
// whose cost is its bytes. See work.go.
Work int64
}

// PropertyLabelEmbedded is the key a generator sets to say whether the label
Expand Down
Loading
Loading