Skip to content

Commit 18af150

Browse files
authored
fix(runtime): compute real container CPU% from two samples (#49)
* fix(runtime): compute real container CPU% from two samples ContainerStatsOneShot returns PreCPUStats=0, so the single-sample delta was the container's whole-lifetime CPU time over whole-lifetime system time — a garbage 'current CPU%' on the dashboard. Take two ContainerStatsOneShot samples one second apart and derive the percentage from the A->B delta; extract the pure math into cpuPercent() for unit testing. List() now samples all running containers concurrently so the added latency is ~one interval regardless of container count. Memory now matches 'docker stats' by subtracting MemoryStats.Stats[inactive_file] from Usage. Verified live against Colima: a single-core busy-loop container reports ~101% (was a meaningless lifetime average before). Unit tests cover the math + guards; a Docker-gated integration test (skipped under -short / no daemon) exercises the real path. * test(runtime): make CPU% logic unit-testable, lift patch coverage The Docker integration test skips on GitHub-hosted runners (no reachable daemon), so the two-sample orchestration was uncovered in CI. Extract the pure logic behind a one-method statsClient seam — sampleFromResponse, combineSamples, statsForContainers, toContainerInfo — and cover them with Docker-free unit tests (fake statsClient). runtime.go patch coverage now 98.48%; the integration test is unchanged and still verifies the live path on the mini.
1 parent 43542aa commit 18af150

2 files changed

Lines changed: 591 additions & 26 deletions

File tree

internal/runtime/runtime.go

Lines changed: 145 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"os/exec"
1111
goruntime "runtime"
1212
"strings"
13+
"sync"
14+
"time"
1315

1416
"github.com/GeiserX/CashPilot-Desktop/internal/catalog"
1517
"github.com/docker/docker/api/types/container"
@@ -286,51 +288,168 @@ func (p *DockerProvider) List(ctx context.Context) ([]ContainerInfo, error) {
286288
if err != nil {
287289
return nil, err
288290
}
289-
out := make([]ContainerInfo, 0, len(containers))
290-
for _, c := range containers {
291-
slug := c.Labels[LabelService]
292-
name := ""
293-
if len(c.Names) > 0 {
294-
name = strings.TrimPrefix(c.Names[0], "/")
295-
}
296-
cpu, mem := p.stats(ctx, cli, c.ID, c.State == "running")
297-
out = append(out, ContainerInfo{
298-
Slug: slug,
299-
ContainerID: c.ID,
300-
Name: name,
301-
Image: c.Image,
302-
Status: c.State,
303-
CPUPercent: cpu,
304-
MemoryMB: mem,
305-
})
291+
return p.statsForContainers(ctx, cli, containers), nil
292+
}
293+
294+
// statsForContainers builds the ContainerInfo list, sampling every container's CPU
295+
// and memory concurrently. stats() waits cpuSampleInterval between its two samples,
296+
// so a serial loop would make this O(N * interval); with one goroutine per container
297+
// the added latency stays ~one interval regardless of N. Each goroutine owns its own
298+
// out[i] slot, so no locking is needed. It takes a statsClient (not *client.Client)
299+
// so the concurrency and mapping can be unit-tested with a fake, no Docker daemon.
300+
func (p *DockerProvider) statsForContainers(ctx context.Context, cli statsClient, containers []container.Summary) []ContainerInfo {
301+
out := make([]ContainerInfo, len(containers))
302+
var wg sync.WaitGroup
303+
for i := range containers {
304+
wg.Add(1)
305+
go func(i int) {
306+
defer wg.Done()
307+
c := containers[i]
308+
cpu, mem := p.stats(ctx, cli, c.ID, c.State == "running")
309+
out[i] = toContainerInfo(c, cpu, mem)
310+
}(i)
311+
}
312+
wg.Wait()
313+
return out
314+
}
315+
316+
// toContainerInfo maps a Docker container summary plus its sampled CPU% and memory
317+
// into a ContainerInfo. Split out so the field mapping is unit-testable.
318+
func toContainerInfo(c container.Summary, cpu, mem float64) ContainerInfo {
319+
name := ""
320+
if len(c.Names) > 0 {
321+
name = strings.TrimPrefix(c.Names[0], "/")
322+
}
323+
return ContainerInfo{
324+
Slug: c.Labels[LabelService],
325+
ContainerID: c.ID,
326+
Name: name,
327+
Image: c.Image,
328+
Status: c.State,
329+
CPUPercent: cpu,
330+
MemoryMB: mem,
306331
}
307-
return out, nil
308332
}
309333

310-
func (p *DockerProvider) stats(ctx context.Context, cli *client.Client, containerID string, running bool) (float64, float64) {
334+
// cpuSampleInterval is the delay between the two container-stats samples used to
335+
// compute a live CPU percentage. Docker's one-shot stats endpoint zeroes
336+
// PreCPUStats, so a single sample makes cpuDelta the container's entire lifetime
337+
// CPU time and systemDelta the entire system CPU time — a lifetime average, not
338+
// current load. Two time-separated samples give a true "CPU% right now".
339+
const cpuSampleInterval = 1 * time.Second
340+
341+
// containerSample holds the raw counters read from one ContainerStatsOneShot call.
342+
type containerSample struct {
343+
cpuTotal uint64 // CPUStats.CPUUsage.TotalUsage
344+
systemCPU uint64 // CPUStats.SystemUsage
345+
onlineCPUs float64 // CPUStats.OnlineCPUs, falling back to len(PercpuUsage)
346+
memoryMB float64 // docker-stats-style memory (Usage minus inactive_file)
347+
}
348+
349+
// statsClient is the small subset of *client.Client that the sampling code needs.
350+
// Narrowing it to an interface lets stats(), sampleStats() and statsForContainers()
351+
// be unit-tested with a fake that returns canned stats, with no Docker daemon.
352+
type statsClient interface {
353+
ContainerStatsOneShot(ctx context.Context, containerID string) (container.StatsResponseReader, error)
354+
}
355+
356+
// stats returns the container's current CPU percentage and memory in MB. It reads
357+
// two stats samples cpuSampleInterval apart and derives the percentage from the
358+
// delta between them; a single one-shot sample would report a meaningless lifetime
359+
// average (see cpuSampleInterval). If ctx is cancelled during the wait it returns
360+
// 0 CPU with sample A's memory, which is still a valid reading.
361+
func (p *DockerProvider) stats(ctx context.Context, cli statsClient, containerID string, running bool) (float64, float64) {
311362
if !running {
312363
return 0, 0
313364
}
365+
a, ok := sampleStats(ctx, cli, containerID)
366+
if !ok {
367+
return 0, 0
368+
}
369+
select {
370+
case <-ctx.Done():
371+
return 0, a.memoryMB
372+
case <-time.After(cpuSampleInterval):
373+
}
374+
b, ok := sampleStats(ctx, cli, containerID)
375+
if !ok {
376+
return 0, a.memoryMB
377+
}
378+
return combineSamples(a, b)
379+
}
380+
381+
// sampleStats reads one ContainerStatsOneShot sample and extracts its counters via
382+
// sampleFromResponse. ok is false if the sample cannot be read or decoded.
383+
func sampleStats(ctx context.Context, cli statsClient, containerID string) (containerSample, bool) {
314384
reader, err := cli.ContainerStatsOneShot(ctx, containerID)
315385
if err != nil {
316-
return 0, 0
386+
return containerSample{}, false
317387
}
318388
defer reader.Body.Close()
319389
var stats container.StatsResponse
320390
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
321-
return 0, 0
391+
return containerSample{}, false
322392
}
323-
memoryMB := float64(stats.MemoryStats.Usage) / 1024 / 1024
324-
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage)
325-
systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage)
393+
return sampleFromResponse(stats), true
394+
}
395+
396+
// sampleFromResponse extracts the counters needed to compute CPU% and memory from an
397+
// already-decoded stats response. It is pure (no IO), so the extraction — including
398+
// the OnlineCPUs==0 → len(PercpuUsage) fallback and the inactive_file memory
399+
// adjustment via memoryMB — is unit-testable without a Docker daemon.
400+
func sampleFromResponse(stats container.StatsResponse) containerSample {
326401
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
327402
if onlineCPUs == 0 {
328403
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
329404
}
405+
return containerSample{
406+
cpuTotal: stats.CPUStats.CPUUsage.TotalUsage,
407+
systemCPU: stats.CPUStats.SystemUsage,
408+
onlineCPUs: onlineCPUs,
409+
memoryMB: memoryMB(stats.MemoryStats),
410+
}
411+
}
412+
413+
// combineSamples derives the current CPU percentage and memory (MB) from two samples
414+
// taken cpuSampleInterval apart: a is the earlier sample, b the later one. CPU% comes
415+
// from the A→B delta (see cpuPercent, which applies the guards) and memory from the
416+
// more recent sample b. It is pure so the two-sample combination is unit-testable
417+
// without a Docker daemon.
418+
func combineSamples(a, b containerSample) (float64, float64) {
419+
return cpuPercent(a.cpuTotal, b.cpuTotal, a.systemCPU, b.systemCPU, b.onlineCPUs), b.memoryMB
420+
}
421+
422+
// cpuPercent computes the Docker-style CPU percentage from two samples:
423+
//
424+
// (cpuDelta / systemDelta) * onlineCPUs * 100
425+
//
426+
// cpuDelta and systemDelta are the differences between the current (cur) and
427+
// previous (pre) counters. Deltas are computed in float64 so that a counter which
428+
// appears to move backwards produces a non-positive delta and trips the guard
429+
// instead of underflowing an unsigned subtraction. It returns 0 when either delta
430+
// is non-positive or onlineCPUs is non-positive.
431+
//
432+
// NOTE: passing a single one-shot sample (pre counters = 0) reproduces the original
433+
// bug — cpuDelta becomes lifetime CPU and systemDelta lifetime system time, so the
434+
// result is a lifetime average that does not reflect current load.
435+
func cpuPercent(preTotal, curTotal, preSystem, curSystem uint64, onlineCPUs float64) float64 {
436+
cpuDelta := float64(curTotal) - float64(preTotal)
437+
systemDelta := float64(curSystem) - float64(preSystem)
330438
if systemDelta <= 0 || cpuDelta <= 0 || onlineCPUs <= 0 {
331-
return 0, memoryMB
439+
return 0
440+
}
441+
return (cpuDelta / systemDelta) * onlineCPUs * 100
442+
}
443+
444+
// memoryMB converts Docker's MemoryStats to megabytes the way `docker stats` does.
445+
// MemoryStats.Usage includes reclaimable page cache, so inactive_file is subtracted
446+
// when the key is present and not larger than Usage; otherwise raw Usage is used.
447+
func memoryMB(mem container.MemoryStats) float64 {
448+
usage := mem.Usage
449+
if inactive, ok := mem.Stats["inactive_file"]; ok && inactive <= usage {
450+
usage -= inactive
332451
}
333-
return (cpuDelta / systemDelta) * onlineCPUs * 100, memoryMB
452+
return float64(usage) / 1024 / 1024
334453
}
335454

336455
func dockerClient() (*client.Client, error) {

0 commit comments

Comments
 (0)