From 1a6212c859cd57de02c80e2b20f2a4439dd1b305 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 12 Jun 2026 14:51:16 +0200 Subject: [PATCH] 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 --- cmd/display/tty.go | 62 +++++++++++++++--------- cmd/display/tty_test.go | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 21 deletions(-) diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 4e9b5d55a..7285b2be9 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -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), } } diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go index bddf05f0c..f5dd67442 100644 --- a/cmd/display/tty_test.go +++ b/cmd/display/tty_test.go @@ -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) + } + } +}