diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 4e9b5d55a7..c93962c9bf 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 } } @@ -448,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 } @@ -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..30a56aa2c9 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" @@ -269,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" @@ -504,3 +524,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) + } + } +}