Skip to content

fix(runtime): compute real container CPU% from two samples#49

Merged
GeiserX merged 2 commits into
mainfrom
fix/container-cpu-percent
Jul 5, 2026
Merged

fix(runtime): compute real container CPU% from two samples#49
GeiserX merged 2 commits into
mainfrom
fix/container-cpu-percent

Conversation

@GeiserX

@GeiserX GeiserX commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Problem

The dashboard showed garbage container CPU%. DockerProvider.stats read a single ContainerStatsOneShot sample, but Docker returns PreCPUStats = 0 in one-shot mode — so cpuDelta was the container's entire lifetime CPU time and systemDelta the entire system time. The ratio is a lifetime average vs. total system time, not the instantaneous usage.

Fix

  • Take two ContainerStatsOneShot samples cpuSampleInterval (1s) apart and derive CPU% from the A→B delta (respects ctx cancellation).
  • Extract the arithmetic into a pure cpuPercent(preTotal, curTotal, preSystem, curSystem, onlineCPUs) (deltas in float64 so a backwards counter trips the guard instead of underflowing) — unit-tested.
  • List() samples all running containers concurrently (goroutine + WaitGroup, each writing its own slot), so added latency is ~one interval regardless of container count, not O(N·1s).
  • Memory now matches docker stats by subtracting MemoryStats.Stats["inactive_file"] from Usage (guarded).

Verification (live, on Colima / Docker 29.5.2)

  • Unit tests: known-answer 40%, all guard cases (zero/backwards deltas), and a case locking the single-sample-bug intent.
  • Docker-gated integration test (skips under -short / when no daemon): creates a uniquely-named throwaway busybox busy-loop container, runs the real two-sample stats(), asserts CPU% is finite/>0/<= onlineCPUs*100+slack, and force-removes it (never touches other containers).

Observed live: a single-core busy loop reports ~101% (correct). go build/vet/test -race green; test -race -short green with the integration test correctly skipped.

Summary by CodeRabbit

  • Bug Fixes

    • Improved container CPU and memory reporting to better match live usage, with more reliable results for running containers.
    • Added safeguards so metrics return 0 when usage data is invalid or unavailable.
    • Updated memory reporting to account for inactive cache, aligning values more closely with what users see in familiar container stats tools.
  • Tests

    • Expanded automated coverage for CPU and memory calculations.
    • Added an integration test that verifies live container stats against a real Docker environment.

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.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeiserX, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a66e1d5-7089-4a7b-a323-49c235515dd0

📥 Commits

Reviewing files that changed from the base of the PR and between 27a18f0 and f0395c8.

📒 Files selected for processing (2)
  • internal/runtime/runtime.go
  • internal/runtime/runtime_test.go
📝 Walkthrough

Walkthrough

Docker container resource sampling was reworked to use two time-separated ContainerStatsOneShot reads for CPU% calculation instead of a single sample, with List now sampling containers concurrently. New helper functions sampleStats, cpuPercent, and memoryMB were introduced. Unit and Docker-backed integration tests were added.

Changes

Docker stats sampling rework

Layer / File(s) Summary
Concurrent container listing
internal/runtime/runtime.go
List now spawns goroutines to sample CPU/memory concurrently into a preallocated slice instead of appending serially; adds sync/time imports.
Two-sample CPU% and memory helpers
internal/runtime/runtime.go
Introduces cpuSampleInterval, containerSample struct, and reworked stats taking two ContainerStatsOneShot reads; adds sampleStats, cpuPercent, and memoryMB helpers with guards for cancellation and non-positive deltas.
Unit and integration tests
internal/runtime/runtime_test.go
Adds tests for cpuPercent and memoryMB (deltas, guards, regression), a dialTestDocker helper, and TestDockerStatsIntegrationReportsLiveCPU validating live CPU/memory stats against a throwaway busybox container.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Related PRs: None identified.

Suggested labels: runtime, testing, enhancement

Suggested reviewers: None identified.

Poem:
A rabbit watched two stats collide in time,
CPU deltas whispered, memory kept in line.
No more single snapshots, guesses gone astray —
Two samples, one truth, the busy loop's ballet.
Hop, test, and verify — the containers run just fine.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main fix: computing container CPU% from two samples.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/container-cpu-percent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 61.91%. Comparing base (43542aa) to head (f0395c8).

Files with missing lines Patch % Lines
internal/runtime/runtime.go 98.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main      #49      +/-   ##
==========================================
+ Coverage   58.81%   61.91%   +3.10%     
==========================================
  Files           8        8              
  Lines        1748     1783      +35     
==========================================
+ Hits         1028     1104      +76     
+ Misses        639      597      -42     
- Partials       81       82       +1     
Files with missing lines Coverage Δ
internal/runtime/runtime.go 46.75% <98.33%> (+15.02%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/runtime/runtime_test.go (1)

191-286: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on Docker calls in the integration test path.

dialTestDocker and TestDockerStatsIntegrationReportsLiveCPU use context.Background() for every Docker call (Ping, pullImage, ContainerCreate, ContainerStart, sampleStats, p.stats). If the daemon stalls (e.g., a wedged socket rather than an outright connection refusal), the test hangs instead of failing/skipping, tying up the CI runner instead of surfacing a clear failure.

🐛 Proposed fix: bound the whole test with a timeout context
 func TestDockerStatsIntegrationReportsLiveCPU(t *testing.T) {
 	if testing.Short() {
 		t.Skip("skipping docker integration test in -short mode")
 	}
 	cli := dialTestDocker(t)
 	defer cli.Close()
 
-	ctx := context.Background()
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/runtime_test.go` around lines 191 - 286, The Docker
integration path in dialTestDocker and TestDockerStatsIntegrationReportsLiveCPU
uses unbounded context.Background() calls, so a stalled daemon can hang the
test. Introduce a timeout-scoped context for the entire test flow and pass it
through Ping, pullImage, ContainerCreate, ContainerStart, sampleStats, and
p.stats, while keeping cleanup on a fresh background context if needed. Use the
existing dialTestDocker and TestDockerStatsIntegrationReportsLiveCPU entry
points to thread the bounded context consistently.
internal/runtime/runtime.go (1)

291-321: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the stats fan-out. Per-index slot writes are race-safe, so the concurrency model itself is fine. But this spawns one goroutine per container with no ceiling, and each holds a Docker connection for ~1s across two ContainerStatsOneShot reads. On a host with many managed containers that's a burst of simultaneous connections that can exhaust the client's transport pool and hammer the daemon. Cap it with a semaphore.

♻️ Bound concurrency with a semaphore
 	out := make([]ContainerInfo, len(containers))
 	var wg sync.WaitGroup
+	sem := make(chan struct{}, 16) // cap concurrent stats sampling
 	for i := range containers {
 		wg.Add(1)
 		go func(i int) {
 			defer wg.Done()
+			sem <- struct{}{}
+			defer func() { <-sem }()
 			c := containers[i]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/runtime.go` around lines 291 - 321, The List path in
runtime.go is fanning out one goroutine per container with no limit, which can
overwhelm the Docker client/daemon when stats are sampled. Update the container
iteration in the List-related logic so the existing p.stats calls still run
concurrently but are bounded by a semaphore or worker cap, keeping the out[i]
writes and WaitGroup flow intact while limiting simultaneous
ContainerStatsOneShot requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/runtime/runtime_test.go`:
- Around line 191-286: The Docker integration path in dialTestDocker and
TestDockerStatsIntegrationReportsLiveCPU uses unbounded context.Background()
calls, so a stalled daemon can hang the test. Introduce a timeout-scoped context
for the entire test flow and pass it through Ping, pullImage, ContainerCreate,
ContainerStart, sampleStats, and p.stats, while keeping cleanup on a fresh
background context if needed. Use the existing dialTestDocker and
TestDockerStatsIntegrationReportsLiveCPU entry points to thread the bounded
context consistently.

In `@internal/runtime/runtime.go`:
- Around line 291-321: The List path in runtime.go is fanning out one goroutine
per container with no limit, which can overwhelm the Docker client/daemon when
stats are sampled. Update the container iteration in the List-related logic so
the existing p.stats calls still run concurrently but are bounded by a semaphore
or worker cap, keeping the out[i] writes and WaitGroup flow intact while
limiting simultaneous ContainerStatsOneShot requests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fa9c627c-4afe-4e39-bb3f-cb7fa82407c7

📥 Commits

Reviewing files that changed from the base of the PR and between 43542aa and 27a18f0.

📒 Files selected for processing (2)
  • internal/runtime/runtime.go
  • internal/runtime/runtime_test.go

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.
@GeiserX GeiserX merged commit 18af150 into main Jul 5, 2026
5 checks passed
@GeiserX GeiserX deleted the fix/container-cpu-percent branch July 5, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant