From 4fa0a6019f20c464a3b6217188ecaa720d99ac56 Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 12 Jun 2026 14:51:16 +0200 Subject: [PATCH 1/2] 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 4e9b5d55a7..7285b2be9d 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 bddf05f0cf..f5dd674427 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) + } + } +} From 5d394c74a4ea07c8f02a6d6872213ed70ce4e1ca Mon Sep 17 00:00:00 2001 From: Guillaume Lours Date: Fri, 12 Jun 2026 15:07:54 +0200 Subject: [PATCH 2/2] fix(progress): measure taskID width in runes, not bytes maxBeforeStatusWidth used len(l.taskID) (bytes) while applyPadding used utf8.RuneCountInString (runes). For ASCII task IDs the two agree and no symptom surfaces, but a taskID containing multi-byte UTF-8 chars (CJK, emoji, accented Latin) reported a width larger than its visual columns. computeOverflow then triggered truncation where none was needed, and truncateLongestTaskID's byte-indexed slice could land mid-multibyte sequence, corrupting the displayed string. Align the two measurements on rune count. Signed-off-by: Guillaume Lours --- cmd/display/tty.go | 2 +- cmd/display/tty_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 7285b2be9d..c93962c9bf 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -449,7 +449,7 @@ func maxBeforeStatusWidth(lines []lineData) int { var maxWidth int for i := range lines { l := &lines[i] - width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress) + width := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress) if width > maxWidth { maxWidth = width } diff --git a/cmd/display/tty_test.go b/cmd/display/tty_test.go index f5dd674427..30a56aa2c9 100644 --- a/cmd/display/tty_test.go +++ b/cmd/display/tty_test.go @@ -270,6 +270,25 @@ func TestAdjustLineWidth_TaskIDCorrectlyTruncated(t *testing.T) { assert.Assert(t, strings.HasSuffix(lines[0].taskID, "..."), "truncated taskID should end with ...") } +// TestAdjustLineWidth_MultiByteTaskIDFits guards against drift between +// applyPadding (rune-based) and maxBeforeStatusWidth (formerly byte-based): +// a byte-based measurement falsely flags overflow for multi-byte taskIDs. +func TestAdjustLineWidth_MultiByteTaskIDFits(t *testing.T) { + w := &ttyWriter{} + taskID := "Image 测试测试" // 10 runes, 18 bytes + lines := []lineData{{ + taskID: taskID, + status: "Pulling", + }} + + // terminalWidth=30 fits in runes (3+10+1+7+1+4 = 26) but not in bytes + // (3+18+1+7+1+4 = 34), so a byte-based measurement would truncate. + w.adjustLineWidth(lines, 4, 30) + + assert.Equal(t, taskID, lines[0].taskID, + "taskID should not be modified when it fits terminal width in runes") +} + func TestAdjustLineWidth_NoTruncationNeeded(t *testing.T) { w := &ttyWriter{} originalDetails := "short"