Skip to content
Open
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
64 changes: 42 additions & 22 deletions cmd/display/tty.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Comment on lines +483 to +493

// 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 {
Expand Down Expand Up @@ -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),
}
}

Expand Down
120 changes: 120 additions & 0 deletions cmd/display/tty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package display
import (
"bytes"
"context"
"fmt"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
}
Loading