cli/command/container: prevent panic during stats on empty event Actor.ID

This code was missing a check for the ID field before truncating it to a
shorter length for presentation. This would result in a panic if an event
would either have an empty ID field or a shorter length ID;

    panic: runtime error: slice bounds out of range [:12] with length 0

    goroutine 82 [running]:
    github.com/docker/cli/cli/command/container.RunStats.func2({{0x0, 0x0}, {0x0, 0x0}, {0x0, 0x0}, {0x40001fcba0, 0x9}, {0x40001fcba9, 0x5}, ...})
        /go/src/github.com/docker/cli/cli/command/container/stats.go:146 +0x1d0
    created by github.com/docker/cli/cli/command/container.(*eventHandler).watch in goroutine 6
        /go/src/github.com/docker/cli/cli/command/container/stats.go:363 +0x1c8

We need to look at this code in general; the truncated ID is passed to
NewStats, which uses the ID to propagate the `Container` field in the
`StatsEntry` struct. which is not used in the default format used by
`docker stats` and, having the same content as the `ID` field on the
same struct, doesn't make it very useful, other than being able to
present it under a `CONTAINER` column (instead of `CONTAINER ID`);
we should consider deprecating it; there may be some subtle things
to look into here; the `Container` field originally held the container
name. This was changed in [moby@ef915fd], which introduced separate
`ID` and `Name` fields, renaming the old `Name` field to container.

Looking at [`Stats.SetStatistics()`] and related code in [stats_helpers.go],
the `Container` field is used as the "canonical" reference for the stats
record; this allows the stats _data_ to be refreshed when a new stats
sample arrives for the same container (also see [moby@929a77b], which
moved locking to the `Stats` wrapper struct). This construct allows to
account for intermediate states, where a stats sample was incomplete
or could produce an error; in that case, the reference to the container
for which the stats were sampled is kept to allow removing a container
from the list once the container was removed. We should consider removing
`Container` as a formatting option, and moving the `Container` field to
the outer struct; this makes the outer struct responsible for keeping a
reference to the container, allowing the `StatsEntry` as a whole to be
replaced atomically.

This patch only addresses the panic;

- It changes the logic to preserve the container ID verbatim instead
  of truncating. This allows stats samples to be matched against the
  `Actor.ID` as-is.
- Truncating the `Container` is moved to the presentation logic;
  currently this does not take `--no-trunc` into account to keep
  the existing behavior, but we can (should) consider adding this.
- Logging is improved to use structured logs, and an extra check is
  added to prevent empty IDs from being added as watcher.

[`Stats.SetStatistics()`]: 82281087e3/cli/command/container/formatter_stats.go (L88-L94)
[moby@ef915fd]: ef915fd036
[moby@929a77b]: 929a77b814
[stats_helpers.go]: 82281087e3/cli/command/container/stats_helpers.go (L26-L51)

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn
2025-09-11 13:00:53 +02:00
parent b9314938b7
commit 9b79e48646
2 changed files with 11 additions and 5 deletions

View File

@ -167,6 +167,7 @@ func (c *statsContext) Container() string {
}
func (c *statsContext) Name() string {
// TODO(thaJeztah): make this explicitly trim the "/" prefix, not just any char.
if len(c.s.Name) > 1 {
return c.s.Name[1:]
}

View File

@ -134,7 +134,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
eh := newEventHandler()
if options.All {
eh.setHandler(events.ActionCreate, func(e events.Message) {
s := NewStats(e.Actor.ID[:12])
s := NewStats(e.Actor.ID)
if cStats.add(s) {
waitFirst.Add(1)
go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
@ -143,7 +143,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
}
eh.setHandler(events.ActionStart, func(e events.Message) {
s := NewStats(e.Actor.ID[:12])
s := NewStats(e.Actor.ID)
if cStats.add(s) {
waitFirst.Add(1)
go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
@ -152,7 +152,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
if !options.All {
eh.setHandler(events.ActionDie, func(e events.Message) {
cStats.remove(e.Actor.ID[:12])
cStats.remove(e.Actor.ID)
})
}
@ -205,7 +205,7 @@ func RunStats(ctx context.Context, dockerCLI command.Cli, options *StatsOptions)
return err
}
for _, ctr := range cs {
s := NewStats(ctr.ID[:12])
s := NewStats(ctr.ID)
if cStats.add(s) {
waitFirst.Add(1)
go collect(ctx, s, apiClient, !options.NoStream, waitFirst)
@ -358,7 +358,12 @@ func (eh *eventHandler) watch(c <-chan events.Message) {
if !exists {
continue
}
logrus.Debugf("event handler: received event: %v", e)
if e.Actor.ID == "" {
logrus.WithField("event", e).Errorf("event handler: received %s event with empty ID", e.Action)
continue
}
logrus.WithField("event", e).Debugf("event handler: received %s event for: %s", e.Action, e.Actor.ID)
go h(e)
}
}