mirror of
https://github.com/docker/compose.git
synced 2026-06-28 04:03:48 +00:00
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:
parent
f82a21324a
commit
1a6212c859
2 changed files with 142 additions and 21 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue