From 8c2d702a4d8cb716db027748258963f0194560a0 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 16:06:13 +0100 Subject: [PATCH 1/4] 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 | 24 ++++++++++++------------ cli/command/container/stats_helpers.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 861d3e6db2ff..70b11ccd264c 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -297,16 +297,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 } @@ -319,12 +317,14 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) for { select { case <-ticker.C: - cStats.mu.RLock() - ccStats := make([]StatsEntry, 0, len(cStats.cs)) - for _, c := range cStats.cs { + statsList := cStats.snapshot() + if len(statsList) == 0 && !showAll { + return nil + } + ccStats := make([]StatsEntry, 0, len(statsList)) + for _, c := range statsList { ccStats = append(ccStats, c.GetStatistics()) } - cStats.mu.RUnlock() // Start by moving the cursor to the top-left _, _ = fmt.Fprint(&statsTextBuffer, "\033[H") diff --git a/cli/command/container/stats_helpers.go b/cli/command/container/stats_helpers.go index 4f7c746be2cd..b6665a2db929 100644 --- a/cli/command/container/stats_helpers.go +++ b/cli/command/container/stats_helpers.go @@ -49,6 +49,20 @@ func (s *stats) isKnownContainer(cid string) (int, bool) { return -1, false } +// snapshot returns a point-in-time copy of stats for the tracked containers. +// The returned slice is safe for use without holding the stats lock. +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 1f1c19eabd8d6dc0a93126917308e2aa1734b076 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 18:45:41 +0100 Subject: [PATCH 2/4] cli/command/container: RunStats: rename buffer var for brevity Signed-off-by: Sebastiaan van Stijn --- cli/command/container/stats.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index 70b11ccd264c..a5d8b6abbc33 100644 --- a/cli/command/container/stats.go +++ b/cli/command/container/stats.go @@ -289,10 +289,9 @@ 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 - + var buf bytes.Buffer statsCtx := formatter.Context{ - Output: &statsTextBuffer, + Output: &buf, Format: NewStatsFormat(format, daemonOSType), } @@ -308,7 +307,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(), buf.String()) return nil } @@ -327,22 +326,22 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } // Start by moving the cursor to the top-left - _, _ = fmt.Fprint(&statsTextBuffer, "\033[H") + _, _ = fmt.Fprint(&buf, "\033[H") if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } - for line := range strings.SplitSeq(statsTextBuffer.String(), "\n") { + for line := range strings.SplitSeq(buf.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(&buf, 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() + // We might have fewer containers than before, so let's clear the remaining text + _, _ = fmt.Fprint(&buf, "\033[J") + _, _ = fmt.Fprint(dockerCLI.Out(), buf.String()) + buf.Reset() if len(ccStats) == 0 && !showAll { return nil From 6a7f0f2369aebd6800c40b19d3e231b9c51b87ff Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 19 Mar 2026 20:56:31 +0100 Subject: [PATCH 3/4] 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 | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cli/command/container/stats.go b/cli/command/container/stats.go index a5d8b6abbc33..3b0a3dc102e6 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" @@ -307,7 +305,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(), buf.String()) + _, _ = dockerCLI.Out().Write(buf.Bytes()) return nil } @@ -326,21 +324,24 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions) } // Start by moving the cursor to the top-left - _, _ = fmt.Fprint(&buf, "\033[H") + _, _ = io.WriteString(&buf, "\033[H") if err := statsFormatWrite(statsCtx, ccStats, daemonOSType, !options.NoTrunc); err != nil { return err } - for line := range strings.SplitSeq(buf.String(), "\n") { + // TODO(thaJeztah): update statsFormatWrite to directly produce the right format + // instead of post-processing the results. + for line := range bytes.SplitSeq(buf.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(&buf, line, "\033[K") + _, _ = buf.Write(line) + _, _ = io.WriteString(&buf, "\033[K") } // We might have fewer containers than before, so let's clear the remaining text - _, _ = fmt.Fprint(&buf, "\033[J") - _, _ = fmt.Fprint(dockerCLI.Out(), buf.String()) + _, _ = io.WriteString(&buf, "\033[J") + _, _ = dockerCLI.Out().Write(buf.Bytes()) buf.Reset() if len(ccStats) == 0 && !showAll { From 236f430baccb890a7f1b333bbbefb0b8faa601c2 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Fri, 20 Mar 2026 09:54:30 +0100 Subject: [PATCH 4/4] 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 626c63142d91..c4a96a56b092 100644 --- a/cli/command/container/formatter_stats.go +++ b/cli/command/container/formatter_stats.go @@ -118,24 +118,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, @@ -147,8 +138,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 {