fix(progress): drop size info from progress bar when line overflows

In narrow terminals (e.g. tmux panes), the TTY progress UI emitted
lines wider than terminalWidth because adjustLineWidth could shrink
details and taskID but never the progress field. When progress
carried the "X.XMB / Y.YMB" size suffix, the truncation loop exited
with overflow > 0 and applyPadding's max(timerPad, 1) floor pushed
the line one char over. tmux then wrapped the line visually while
print() kept counting logical lines, desyncing aec.Up() on the next
render and producing the mangled "[+] pull X/Y" header overwriting
prior task lines.

Track the size suffix byte length on lineData and let
adjustLineWidth drop it as an intermediate truncation step before
abbreviating the taskID.

Fixes docker/compose#13595

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-06-12 14:51:16 +02:00 committed by Nicolas De loof
parent f82a21324a
commit 1a6212c859
2 changed files with 142 additions and 21 deletions

View file

@ -268,16 +268,17 @@ func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
// lineData holds pre-computed formatting for a task line
type lineData struct {
spinner string // rendered spinner with color
prefix string // dry-run prefix if any
taskID string // possibly abbreviated
progress string // progress bar and size info
status string // rendered status with color
details string // possibly abbreviated
timer string // rendered timer with color
statusPad int // padding before status to align
timerPad int // padding before timer to align
statusColor colorFunc
spinner string // rendered spinner with color
prefix string // dry-run prefix if any
taskID string // possibly abbreviated
progress string // progress bar and (optionally) size info appended
progressSizeBytes int // byte length of the trailing size suffix in progress, 0 if none
status string // rendered status with color
details string // possibly abbreviated
timer string // rendered timer with color
statusPad int // padding before status to align
timerPad int // padding before timer to align
statusColor colorFunc
}
func (w *ttyWriter) print() {
@ -424,8 +425,8 @@ func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidt
break
}
// First try to truncate details, then taskID
if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) {
// Drop ancillary content (details, progress size info) before touching the taskID.
if !truncateDetails(lines, overflow) && !truncateProgressSize(lines) && !truncateLongestTaskID(lines, overflow, minIDLen) {
break // Can't truncate further
}
}
@ -476,6 +477,21 @@ func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen,
return maxOverflow
}
// truncateProgressSize drops the trailing "X.XMB / Y.YMB" size info from the
// first line whose progress still carries it. Returns true if any line was
// modified. Used to recover from overflow without abbreviating the taskID.
func truncateProgressSize(lines []lineData) bool {
for i := range lines {
l := &lines[i]
if l.progressSizeBytes > 0 {
l.progress = l.progress[:len(l.progress)-l.progressSizeBytes]
l.progressSizeBytes = 0
return true
}
}
return false
}
// truncateDetails tries to truncate the first line's details to reduce overflow.
// Returns true if any truncation was performed.
func truncateDetails(lines []lineData, overflow int) bool {
@ -560,22 +576,26 @@ func (w *ttyWriter) prepareLineData(t *task) lineData {
}
var progress string
var progressSizeBytes int
if len(completion) > 0 {
progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
if !hideDetails {
progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
sizeInfo := fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
progress += sizeInfo
progressSizeBytes = len(sizeInfo)
}
}
return lineData{
spinner: spinner(t),
prefix: prefix,
taskID: t.ID,
progress: progress,
status: t.text,
statusColor: colorFn(t.status),
details: t.details,
timer: fmt.Sprintf("%.1fs", elapsed),
spinner: spinner(t),
prefix: prefix,
taskID: t.ID,
progress: progress,
progressSizeBytes: progressSizeBytes,
status: t.text,
statusColor: colorFn(t.status),
details: t.details,
timer: fmt.Sprintf("%.1fs", elapsed),
}
}

View file

@ -19,6 +19,7 @@ package display
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"testing"
@ -504,3 +505,103 @@ func TestDoneDeadlockFix(t *testing.T) {
t.Fatal("Deadlock detected: Done() did not complete within 5 seconds")
}
}
// TestAdjustLineWidth_WideProgressForcesSizeInfoDrop is the unit-level
// regression test for docker/compose#13595. When progress contains the
// " X.XMB / Y.YMB" size suffix and the bar makes beforeStatus large enough
// to overflow terminalWidth, taskID truncation alone cannot make the line
// fit: applyPadding's max(timerPad, 1) floor adds one char back, and the
// "..."-padding minimum (10 chars) on taskID puts a lower bound on
// beforeStatus. The size info portion of progress must therefore be
// droppable when overflow can't be eliminated otherwise.
func TestAdjustLineWidth_WideProgressForcesSizeInfoDrop(t *testing.T) {
w := &ttyWriter{}
// Mirror prepareLineData's layout: " [bar]" + " %7s / %-7s".
sizeSuffix := " 50MB / 100MB "
progress := " [" + strings.Repeat("⣿", 30) + "]" + sizeSuffix
lines := []lineData{{
taskID: "Image mariadb:11",
progress: progress,
progressSizeBytes: len(sizeSuffix),
status: "Pulling",
statusColor: nocolor,
spinner: " ",
timer: "5.4s",
}}
terminalWidth := 60
timerLen := 4
w.adjustLineWidth(lines, timerLen, terminalWidth)
w.applyPadding(lines, terminalWidth, timerLen)
rendered := strings.TrimRight(lineText(lines[0]), "\n")
assert.Assert(t, lenAnsi(rendered) <= terminalWidth,
"line length %d should not exceed terminal width %d: %q",
lenAnsi(rendered), terminalWidth, rendered)
}
// addParentWithDownloadingChildren wires a parent task with N children whose
// non-zero totals trigger the " X.XMB / Y.YMB" suffix in prepareLineData's
// progress field. Used by the multi-render regression test below.
func addParentWithDownloadingChildren(w *ttyWriter, parentID string, children int, totalBytes int64) {
parent := &task{
ID: parentID,
parents: make(map[string]struct{}),
startTime: time.Now(),
text: "Pulling",
status: api.Working,
spinner: NewSpinner(),
}
w.tasks[parent.ID] = parent
w.ids = append(w.ids, parent.ID)
for i := range children {
c := &task{
ID: fmt.Sprintf("%s/layer%d", parentID, i),
parents: map[string]struct{}{parent.ID: {}},
startTime: time.Now(),
text: "Downloading",
status: api.Working,
total: totalBytes / int64(children),
current: totalBytes / int64(children) / 2,
percent: 50,
spinner: NewSpinner(),
}
w.tasks[c.ID] = c
w.ids = append(w.ids, c.ID)
}
}
// TestPrintWithDimensions_MultipleRendersFit verifies the cross-render aspect
// of docker/compose#13595: even a single overflowing line desyncs the cursor
// on the following tick because aec.Up(numLines) counts logical lines while
// the terminal wraps visual lines. Use many concurrent parent tasks with
// wide progress bars in a narrow terminal so adjustLineWidth's truncation
// loop can't bring every line under terminalWidth without dropping size
// info from progress.
func TestPrintWithDimensions_MultipleRendersFit(t *testing.T) {
w, buf := newTestWriter()
// Two parents so the truncation loop must walk multiple lines; 30 children
// per parent makes each progress bar wide enough that taskID truncation
// alone can't bring the line under terminalWidth.
for i := range 2 {
addParentWithDownloadingChildren(w,
"Image very-long-name-image-"+string(rune('a'+i))+":v1.2.3",
30, 100_000_000)
}
terminalWidth := 60
for tick := range 10 {
for _, t := range w.tasks {
if t.status == api.Working && t.total > 0 {
t.current = min(t.current+t.total/10, t.total)
}
}
buf.Reset()
w.printWithDimensions(terminalWidth, 24)
for i, line := range extractLines(buf) {
assert.Assert(t, lenAnsi(line) <= terminalWidth,
"tick %d line %d has length %d > terminalWidth %d: %q",
tick, i, lenAnsi(line), terminalWidth, line)
}
}
}