From 4c5efd61ead2284fcdc5b09e0117c487401af530 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 2 Apr 2026 13:46:28 +0200 Subject: [PATCH 1/5] cli/command/container: fix buffer reuse when printing stats Don't write lines back into the same buffer that's being read from when clearing lines; add a separate output buffer to construct the output, then write it to the CLI's output at once (to prevent terminal flicker). Relates to / introduced in cb2f95ceee76c7166e95ec1b59c3e2fbac9b0079. Signed-off-by: Sebastiaan van Stijn --- cli/command/container/stats.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 1902fac833fe..9d882e32bfc2 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -291,6 +291,10 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) // Once formatted, it will be printed in one write to avoid screen flickering. var statsTextBuffer bytes.Buffer + // frameBuf holds the final terminal frame, including cursor movement and + // line-clearing escape sequences, written in a single pass to avoid flicker. + var frameBuf bytes.Buffer + statsCtx := formatter.Context{ Output: &statsTextBuffer, Format: NewStatsFormat(format, daemonOSType), @@ -319,6 +323,8 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) for { select { case <-ticker.C: + statsTextBuffer.Reset() + frameBuf.Reset() cStats.mu.RLock() ccStats := make([]StatsEntry, 0, len(cStats.cs)) for _, c := range cStats.cs { @@ -326,23 +332,21 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } cStats.mu.RUnlock() - // Start by moving the cursor to the top-left - _, _ = fmt.Fprint(&statsTextBuffer, "\033[H") - if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } + // Start by moving the cursor to the top-left + _, _ = fmt.Fprint(&frameBuf, "\033[H") + for line := range strings.SplitSeq(statsTextBuffer.String(), "\n") { // In case the new text is shorter than the one we are writing over, // we'll append the "erase line" escape sequence to clear the remaining text. - _, _ = fmt.Fprintln(&statsTextBuffer, line, "\033[K") + _, _ = fmt.Fprintln(&frameBuf, line, "\033[K") } // We might have fewer containers than before, so let's clear the remaining text - _, _ = fmt.Fprint(&statsTextBuffer, "\033[J") - - _, _ = fmt.Fprint(dockerCLI.Out(), statsTextBuffer.String()) - statsTextBuffer.Reset() + _, _ = fmt.Fprint(&frameBuf, "\033[J") + _, _ = fmt.Fprint(dockerCLI.Out(), frameBuf.String()) if len(ccStats) == 0 && !showAll { return nil From ee88c60a5e5b5a9a51752547b6a8d0622bdfbaf9 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 16:06:13 +0100 Subject: [PATCH 2/5] cli/command/container: stats: add snapshot method Move logic to capture a snapshot of the current stats to the stats struct. Signed-off-by: Sebastiaan van Stijn --- cli/command/container/stats.go | 30 ++++++++++++-------------- cli/command/container/stats_helpers.go | 16 ++++++++++++++ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 9d882e32bfc2..9d4c5ff559ab 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -301,16 +301,14 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } if options.NoStream { - cStats.mu.RLock() - ccStats := make([]StatsEntry, 0, len(cStats.cs)) - for _, c := range cStats.cs { - ccStats = append(ccStats, c.GetStatistics()) - } - cStats.mu.RUnlock() - - if len(ccStats) == 0 { + statsList := cStats.snapshot() + if len(statsList) == 0 { return nil } + ccStats := make([]StatsEntry, 0, len(statsList)) + for _, c := range statsList { + ccStats = append(ccStats, c.GetStatistics()) + } if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } @@ -325,12 +323,16 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) case <-ticker.C: statsTextBuffer.Reset() frameBuf.Reset() - cStats.mu.RLock() - ccStats := make([]StatsEntry, 0, len(cStats.cs)) - for _, c := range cStats.cs { + statsList := cStats.snapshot() + if len(statsList) == 0 && !showAll { + // Clear screen + _, _ = io.WriteString(dockerCLI.Out(), "\033[H\033[J") + return nil + } + ccStats := make([]StatsEntry, 0, len(statsList)) + for _, c := range statsList { ccStats = append(ccStats, c.GetStatistics()) } - cStats.mu.RUnlock() if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err @@ -347,10 +349,6 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) // We might have fewer containers than before, so let's clear the remaining text _, _ = fmt.Fprint(&frameBuf, "\033[J") _, _ = fmt.Fprint(dockerCLI.Out(), frameBuf.String()) - - if len(ccStats) == 0 && !showAll { - return nil - } case err, ok := <-closeChan: if !ok || err == nil || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { // Suppress "unexpected EOF" errors in the CLI so that diff --git a/cli/command/container/stats_helpers.go b/cli/command/container/stats_helpers.go index 4f7c746be2cd..66571b530942 100644 --- a/cli/command/container/stats_helpers.go +++ b/cli/command/container/stats_helpers.go @@ -49,6 +49,22 @@ func (s *stats) isKnownContainer(cid string) (int, bool) { return -1, false } +// snapshot returns a point-in-time copy of the tracked container list +// (the slice of *Stats pointers). The returned slice is safe for use +// without holding the stats lock, but the underlying Stats values may +// continue to change concurrently. +func (s *stats) snapshot() []*Stats { + s.mu.RLock() + defer s.mu.RUnlock() + if len(s.cs) == 0 { + return nil + } + // https://github.com/golang/go/issues/53643 + cp := make([]*Stats, len(s.cs)) + copy(cp, s.cs) + return cp +} + func collect(ctx context.Context, s *Stats, cli client.ContainerAPIClient, streamStats bool, waitFirst *sync.WaitGroup) { //nolint:gocyclo var getFirst bool From d92d1187fc74b590a6a6fff4e1a5c773c4be15cd Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 18:45:41 +0100 Subject: [PATCH 3/5] cli/command/container: RunStats: rename buffer var for brevity Signed-off-by: Sebastiaan van Stijn --- cli/command/container/stats.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 9d4c5ff559ab..41b1bd0c5394 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -287,16 +287,16 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } } - // Buffer to store formatted stats text. - // Once formatted, it will be printed in one write to avoid screen flickering. - var statsTextBuffer bytes.Buffer + // renderBuf holds the formatted stats output produced by statsFormatWrite. + // It does not include any terminal control sequences. + var renderBuf bytes.Buffer // frameBuf holds the final terminal frame, including cursor movement and // line-clearing escape sequences, written in a single pass to avoid flicker. var frameBuf bytes.Buffer statsCtx := formatter.Context{ - Output: &statsTextBuffer, + Output: &renderBuf, Format: NewStatsFormat(format, daemonOSType), } @@ -312,7 +312,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } - _, _ = fmt.Fprint(dockerCLI.Out(), statsTextBuffer.String()) + _, _ = fmt.Fprint(dockerCLI.Out(), renderBuf.String()) return nil } @@ -321,7 +321,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) for { select { case <-ticker.C: - statsTextBuffer.Reset() + renderBuf.Reset() frameBuf.Reset() statsList := cStats.snapshot() if len(statsList) == 0 && !showAll { @@ -341,7 +341,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) // Start by moving the cursor to the top-left _, _ = fmt.Fprint(&frameBuf, "\033[H") - for line := range strings.SplitSeq(statsTextBuffer.String(), "\n") { + for line := range strings.SplitSeq(renderBuf.String(), "\n") { // In case the new text is shorter than the one we are writing over, // we'll append the "erase line" escape sequence to clear the remaining text. _, _ = fmt.Fprintln(&frameBuf, line, "\033[K") From c44a4d975838fa573f3df9a16bf6546296926c84 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 20:56:31 +0100 Subject: [PATCH 4/5] cli/command/container: RunStats: avoid bytes to strings conversions This code is using a `bytes.Buffer` to render the stats, before writing the results to the CLI's output. Let's try to use bytes where possible instead of converting to a string; - Use the buffer's `Write` (and `Out().Write`) to write directly to the buffer/writer where possible. - Use `io.WriteString` instead of `fmt.Printf` - Use `bytes.SplitSeq` instead of `strings.SplitSeq` Signed-off-by: Sebastiaan van Stijn --- cli/command/container/stats.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 41b1bd0c5394..9d4bfce51bff 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -7,9 +7,7 @@ import ( "bytes" "context" "errors" - "fmt" "io" - "strings" "sync" "time" @@ -312,7 +310,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } - _, _ = fmt.Fprint(dockerCLI.Out(), renderBuf.String()) + _, _ = dockerCLI.Out().Write(renderBuf.Bytes()) return nil } @@ -339,16 +337,20 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } // Start by moving the cursor to the top-left - _, _ = fmt.Fprint(&frameBuf, "\033[H") + _, _ = io.WriteString(&frameBuf, "\033[H") - for line := range strings.SplitSeq(renderBuf.String(), "\n") { + // TODO(thaJeztah): consider wrapping the writer to inject ANSI (line-clearing) during formatting. + // instead of post-processing the results. + for line := range bytes.SplitSeq(renderBuf.Bytes(), []byte{'\n'}) { // In case the new text is shorter than the one we are writing over, // we'll append the "erase line" escape sequence to clear the remaining text. - _, _ = fmt.Fprintln(&frameBuf, line, "\033[K") + _, _ = frameBuf.Write(line) + _, _ = io.WriteString(&frameBuf, "\033[K") + _ = frameBuf.WriteByte('\n') } // We might have fewer containers than before, so let's clear the remaining text - _, _ = fmt.Fprint(&frameBuf, "\033[J") - _, _ = fmt.Fprint(dockerCLI.Out(), frameBuf.String()) + _, _ = io.WriteString(&frameBuf, "\033[J") + _, _ = dockerCLI.Out().Write(frameBuf.Bytes()) case err, ok := <-closeChan: if !ok || err == nil || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { // Suppress "unexpected EOF" errors in the CLI so that From e7cbaafa9dc2c5f6103f72e308666ee957221b7b Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 20 Mar 2026 09:54:30 +0100 Subject: [PATCH 5/5] cli/command/container: statsFormatWrite: inline render func Signed-off-by: Sebastiaan van Stijn --- cli/command/container/formatter_stats.go | 33 ++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/cli/command/container/formatter_stats.go b/cli/command/container/formatter_stats.go index a7792f2bc89f..de8e3e98996d 100644 --- a/cli/command/container/formatter_stats.go +++ b/cli/command/container/formatter_stats.go @@ -119,24 +119,15 @@ func NewStats(container string) *Stats { // statsFormatWrite renders the context for a list of containers statistics func statsFormatWrite(ctx formatter.Context, stats []StatsEntry, osType string, trunc bool) error { - render := func(format func(subContext formatter.SubContext) error) error { - for _, cstats := range stats { - statsCtx := &statsContext{ - s: cstats, - os: osType, - trunc: trunc, - } - if err := format(statsCtx); err != nil { - return err - } - } - return nil - } + // TODO(thaJeztah): this should be taken from the (first) StatsEntry instead. + // also, assuming all stats are for the same platform (and basing the + // column headers on that) won't allow aggregated results, which could + // be mixed platform. memUsage := memUseHeader if osType == winOSType { memUsage = winMemUseHeader } - statsCtx := statsContext{} + statsCtx := statsContext{os: osType} statsCtx.Header = formatter.SubHeaderContext{ "Container": containerHeader, "Name": formatter.NameHeader, @@ -148,8 +139,18 @@ func statsFormatWrite(ctx formatter.Context, stats []StatsEntry, osType string, "BlockIO": blockIOHeader, "PIDs": pidsHeader, } - statsCtx.os = osType - return ctx.Write(&statsCtx, render) + return ctx.Write(&statsCtx, func(format func(subContext formatter.SubContext) error) error { + for _, cstats := range stats { + if err := format(&statsContext{ + s: cstats, + os: osType, + trunc: trunc, + }); err != nil { + return err + } + } + return nil + }) } type statsContext struct {