@samitouri / QOSAMI-WSL / commits / 262a2696

CLI: Stats command - fix incorrect CPU % reporting (#40627)

David Bennett committed May 29, 2026 at 11:12 UTC 262a2696e15cf87e42dd0f96e605537c104bbfe4
4 files changed +298 -86
src/windows/wslc/core/AsyncExecution.h new
+100
@@ -0,0 +1,100 @@
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2 +
3 +/*++
4 +
5 +Module Name:
6 +
7 + AsyncExecution.h
8 +
9 +Abstract:
10 +
11 + Provides ForEachAsync, a generic helper for executing a work callback
12 + over a collection concurrently in bounded batches using std::async.
13 +
14 +--*/
15 +#pragma once
16 +
17 +#include <algorithm>
18 +#include <future>
19 +#include <optional>
20 +#include <utility>
21 +#include <vector>
22 +#include <wil/result_macros.h>
23 +
24 +namespace wsl::windows::wslc {
25 +
26 +// Invokes onWork for each element in items concurrently, in batches of batchSize.
27 +// Results are delivered serially to onSuccess. Errors are delivered serially to onError.
28 +//
29 +// This keeps wall time proportional to ceil(N / batchSize) rather than N for operations
30 +// that have inherent per-item latency (e.g. network or IPC calls).
31 +//
32 +// Note: worker threads have no guaranteed per-thread initialization (e.g. COM). Callers
33 +// whose onWork requires per-thread setup (such as CoInitializeEx) are responsible for
34 +// performing it at the start of the onWork lambda.
35 +//
36 +// TWork : TItem -> TResult (called concurrently)
37 +// TSuccess: TResult -> void (called serially)
38 +// TError : (TItem, wil::ResultException) -> void (called serially)
39 +template <typename TItem, typename TWork, typename TSuccess, typename TError>
40 +void ForEachAsync(const std::vector<TItem>& items, TWork onWork, TSuccess onSuccess, TError onError, size_t batchSize = 10)
41 +{
42 + WI_ASSERT(batchSize > 0);
43 + THROW_HR_IF(E_INVALIDARG, batchSize == 0);
44 +
45 + using TResult = decltype(onWork(std::declval<TItem>()));
46 +
47 + struct BatchResult
48 + {
49 + explicit BatchResult(TItem capturedItem) : item(std::move(capturedItem))
50 + {
51 + }
52 +
53 + TItem item;
54 + std::optional<TResult> result;
55 + wil::ResultException error{S_OK};
56 + bool hasError{false};
57 + };
58 +
59 + for (size_t batchStart = 0; batchStart < items.size(); batchStart += batchSize)
60 + {
61 + const size_t batchEnd = std::min(batchStart + batchSize, items.size());
62 +
63 + std::vector<std::future<BatchResult>> futures;
64 + futures.reserve(batchEnd - batchStart);
65 +
66 + for (size_t i = batchStart; i < batchEnd; ++i)
67 + {
68 + const auto& item = items[i];
69 + futures.push_back(std::async(std::launch::async, [&onWork, item]() -> BatchResult {
70 + BatchResult result{item};
71 + try
72 + {
73 + result.result = onWork(item);
74 + }
75 + catch (const wil::ResultException& ex)
76 + {
77 + result.hasError = true;
78 + result.error = ex;
79 + }
80 + return result;
81 + }));
82 + }
83 +
84 + for (auto& future : futures)
85 + {
86 + auto batchResult = future.get();
87 +
88 + if (batchResult.hasError)
89 + {
90 + onError(batchResult.item, batchResult.error);
91 + }
92 + else if (batchResult.result.has_value())
93 + {
94 + onSuccess(*batchResult.result);
95 + }
96 + }
97 + }
98 +}
99 +
100 +} // namespace wsl::windows::wslc
src/windows/wslc/tasks/ContainerTasks.cpp
+97 -85
@@ -13,6 +13,7 @@ Abstract:
13 --*/
14 #include "Argument.h"
15 #include "ArgumentValidation.h"
16 +#include "AsyncExecution.h"
17 #include "CLIExecutionContext.h"
18 #include "ContainerModel.h"
19 #include "ContainerService.h"
@@ -61,6 +62,76 @@ std::string FormatBytes(uint64_t bytes)
62 }
63 }
64
65 +nlohmann::json ComputeContainerStatsJson(const wsl::windows::common::docker_schema::ContainerStats& stats)
66 +{
67 + // Calculate CPU %
68 + // Formula matches Docker CLI: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go
69 + double cpuPercent = 0.0;
70 + const auto cpuDelta =
71 + static_cast<double>(stats.cpu_stats.cpu_usage.total_usage) - static_cast<double>(stats.precpu_stats.cpu_usage.total_usage);
72 + const auto systemDelta = static_cast<double>(stats.cpu_stats.system_cpu_usage) - static_cast<double>(stats.precpu_stats.system_cpu_usage);
73 + if (systemDelta > 0.0 && cpuDelta > 0.0)
74 + {
75 + uint32_t onlineCpus = stats.cpu_stats.online_cpus;
76 + if (onlineCpus == 0 && stats.cpu_stats.cpu_usage.percpu_usage.has_value())
77 + {
78 + onlineCpus = static_cast<uint32_t>(stats.cpu_stats.cpu_usage.percpu_usage->size());
79 + }
80 +
81 + cpuPercent = (cpuDelta / systemDelta) * static_cast<double>(onlineCpus) * 100.0;
82 + }
83 +
84 + // Calculate memory %
85 + double memPercent = 0.0;
86 + if (stats.memory_stats.limit > 0)
87 + {
88 + memPercent = (static_cast<double>(stats.memory_stats.usage) / static_cast<double>(stats.memory_stats.limit)) * 100.0;
89 + }
90 +
91 + // Aggregate network I/O
92 + uint64_t netRxBytes = 0;
93 + uint64_t netTxBytes = 0;
94 + if (stats.networks.has_value())
95 + {
96 + for (const auto& [iface, netStats] : *stats.networks)
97 + {
98 + netRxBytes += netStats.rx_bytes;
99 + netTxBytes += netStats.tx_bytes;
100 + }
101 + }
102 +
103 + // Aggregate block I/O
104 + uint64_t blkReadBytes = 0;
105 + uint64_t blkWriteBytes = 0;
106 + if (stats.blkio_stats.io_service_bytes_recursive.has_value())
107 + {
108 + for (const auto& entry : *stats.blkio_stats.io_service_bytes_recursive)
109 + {
110 + if (_stricmp(entry.op.c_str(), "read") == 0)
111 + {
112 + blkReadBytes += entry.value;
113 + }
114 + else if (_stricmp(entry.op.c_str(), "write") == 0)
115 + {
116 + blkWriteBytes += entry.value;
117 + }
118 + }
119 + }
120 +
121 + const auto& containerName = stats.name.empty() ? stats.id : stats.name;
122 +
123 + return {
124 + {"ID", stats.id},
125 + {"Name", containerName},
126 + {"CPUPerc", std::format("{:.2f}%", cpuPercent)},
127 + {"MemUsage", std::format("{} / {}", FormatBytes(stats.memory_stats.usage), FormatBytes(stats.memory_stats.limit))},
128 + {"MemPerc", std::format("{:.2f}%", memPercent)},
129 + {"NetIO", std::format("{} / {}", FormatBytes(netRxBytes), FormatBytes(netTxBytes))},
130 + {"BlockIO", std::format("{} / {}", FormatBytes(blkReadBytes), FormatBytes(blkWriteBytes))},
131 + {"PIDs", stats.pids_stats.current},
132 + };
133 +}
134 +
135 } // namespace
136
137 namespace wsl::windows::wslc::task {
@@ -495,100 +566,41 @@ void ShowContainerStats(CLIExecutionContext& context)
566 }
567 }
568
498 - // Build stats as a json array first for later filtering or display either as json or table format.
569 + // Fetch stats for all containers concurrently in batches. The Docker engine blocks for ~1s
570 + // per request to collect a valid precpu_stats sample, so issuing requests in parallel keeps
571 + // wall time proportional to ceil(N / batchSize) rather than N.
572 nlohmann::json statsJson = nlohmann::json::array();
500 - for (const auto& containerId : containers)
501 - {
502 - wsl::windows::common::docker_schema::ContainerStats stats;
503 - try
504 - {
505 - stats = ContainerService::Stats(session, WideToMultiByte(containerId));
506 - }
507 - catch (const wil::ResultException& ex)
508 - {
573 + wsl::windows::wslc::ForEachAsync<std::wstring>(
574 + containers,
575 + // Work to be done for each container ID on a separate thread.
576 + [&session](const std::wstring& containerId) {
577 + // ContainerService::Stats makes COM calls, so we must ensure COM is initialized on this thread.
578 + auto comCleanup = wil::CoInitializeEx(COINIT_MULTITHREADED);
579 + return ComputeContainerStatsJson(ContainerService::Stats(session, WideToMultiByte(containerId)));
580 + },
581 + // On Success
582 + [&](const nlohmann::json& entry) { statsJson.push_back(entry); },
583 + // On Error
584 + [&](const std::wstring& containerId, wil::ResultException error) {
585 if (!userSpecifiedContainers)
586 {
511 - // If the user did not explicitly specify a container then there may be expected
512 - // race conditions between listing containers and querying stats.
513 - switch (ex.GetErrorCode())
587 + switch (error.GetErrorCode())
588 {
589 case RPC_E_DISCONNECTED:
590 case WSLC_E_CONTAINER_NOT_FOUND:
517 - continue;
518 - }
519 - }
520 -
521 - LOG_HR_MSG(ex.GetErrorCode(), "Failed to get stats for container %ws", containerId.c_str());
522 - throw;
523 - }
524 -
525 - // Calculate CPU %
526 - double cpuPercent = 0.0;
527 - const auto cpuDelta = static_cast<double>(stats.cpu_stats.cpu_usage.total_usage) -
528 - static_cast<double>(stats.precpu_stats.cpu_usage.total_usage);
529 - const auto systemDelta =
530 - static_cast<double>(stats.cpu_stats.system_cpu_usage) - static_cast<double>(stats.precpu_stats.system_cpu_usage);
531 - const auto onlineCpus = stats.cpu_stats.online_cpus > 0 ? stats.cpu_stats.online_cpus : 1u;
532 - if (systemDelta > 0.0 && cpuDelta >= 0.0)
533 - {
534 - cpuPercent = (cpuDelta / systemDelta) * static_cast<double>(onlineCpus) * 100.0;
535 - }
536 -
537 - // Calculate memory %
538 - double memPercent = 0.0;
539 - if (stats.memory_stats.limit > 0)
540 - {
541 - memPercent = (static_cast<double>(stats.memory_stats.usage) / static_cast<double>(stats.memory_stats.limit)) * 100.0;
542 - }
543 -
544 - // Aggregate network I/O
545 - uint64_t netRxBytes = 0;
546 - uint64_t netTxBytes = 0;
547 - if (stats.networks.has_value())
548 - {
549 - for (const auto& [iface, netStats] : *stats.networks)
550 - {
551 - netRxBytes += netStats.rx_bytes;
552 - netTxBytes += netStats.tx_bytes;
553 - }
554 - }
555 -
556 - // Aggregate block I/O
557 - uint64_t blkReadBytes = 0;
558 - uint64_t blkWriteBytes = 0;
559 - if (stats.blkio_stats.io_service_bytes_recursive.has_value())
560 - {
561 - for (const auto& entry : *stats.blkio_stats.io_service_bytes_recursive)
562 - {
563 - if (_stricmp(entry.op.c_str(), "read") == 0)
564 - {
565 - blkReadBytes += entry.value;
566 - }
567 - else if (_stricmp(entry.op.c_str(), "write") == 0)
568 - {
569 - blkWriteBytes += entry.value;
591 + // Container disappeared between list and stats fetch, and
592 + // the user did not specify these containers, so silently skip.
593 + return;
594 }
595 }
572 - }
596
574 - const auto& containerName = stats.name.empty() ? stats.id : stats.name;
575 - const auto cpuPercentStr = std::format("{:.2f}%", cpuPercent);
576 - const auto memPercentStr = std::format("{:.2f}%", memPercent);
577 - const auto memUsage = std::format("{} / {}", FormatBytes(stats.memory_stats.usage), FormatBytes(stats.memory_stats.limit));
578 - const auto netIo = std::format("{} / {}", FormatBytes(netRxBytes), FormatBytes(netTxBytes));
579 - const auto blkIo = std::format("{} / {}", FormatBytes(blkReadBytes), FormatBytes(blkWriteBytes));
580 -
581 - statsJson.push_back({
582 - {"ID", stats.id},
583 - {"Name", containerName},
584 - {"CPUPerc", cpuPercentStr},
585 - {"MemUsage", memUsage},
586 - {"MemPerc", memPercentStr},
587 - {"NetIO", netIo},
588 - {"BlockIO", blkIo},
589 - {"PIDs", stats.pids_stats.current},
590 - });
591 - }
597 + // Failure to retrieve a container should stop execution with
598 + // no container information displayed.
599 + LOG_HR_MSG(error.GetErrorCode(), "Failed to get stats for container %ws", containerId.c_str());
600 + throw error;
601 + },
602 + 10 // Batch Size - chosen to be around typical expected container use while protecting against extreme cases.
603 + );
604
605 FormatType format = FormatType::Table; // Default is table
606 if (context.Args.Contains(ArgType::Format))
src/windows/wslcsession/DockerHTTPClient.cpp
+4 -1
@@ -340,7 +340,10 @@ docker_schema::ContainerStats DockerHTTPClient::ContainerStats(const std::string
340 {
341 auto url = URL::Create("/containers/{}/stats", Id);
342 url.SetParameter("stream", false);
343 - url.SetParameter("one-shot", true);
343 +
344 + // Intentionally omit one-shot=true: the Docker engine blocks internally for ~1s
345 + // to collect a prior sample, and returns a single response with both cpu_stats and
346 + // precpu_stats correctly populated — giving a valid delta for CPU % calculation.
347 return Transaction<EmptyRequest, docker_schema::ContainerStats>(verb::get, url);
348 }
349
test/windows/wslc/WSLCCLIExecutionUnitTests.cpp
+97
@@ -18,6 +18,7 @@ Abstract:
18
19 #include "SessionModel.h"
20
21 +#include "AsyncExecution.h"
22 #include "Command.h"
23 #include "RootCommand.h"
24 #include "ContainerCommand.h"
@@ -407,4 +408,100 @@ class WSLCCLIExecutionUnitTests
408 }
409 }
410 };
411 +
412 +class ForEachAsyncUnitTests
413 +{
414 + WSLC_TEST_CLASS(ForEachAsyncUnitTests)
415 +
416 + TEST_METHOD(ForEachAsync_SuccessCallbackInvokedForAllItems)
417 + {
418 + const std::vector<int> items = {1, 2, 3, 4, 5};
419 + std::vector<int> results;
420 +
421 + ForEachAsync<int>(
422 + items,
423 + [](int item) { return item * 2; },
424 + [&](int result) { results.push_back(result); },
425 + [](int /*item*/, wil::ResultException /*error*/) { VERIFY_FAIL(L"Unexpected error"); });
426 +
427 + VERIFY_ARE_EQUAL(items.size(), results.size());
428 + for (int item : items)
429 + {
430 + VERIFY_IS_TRUE(std::find(results.begin(), results.end(), item * 2) != results.end());
431 + }
432 + }
433 +
434 + TEST_METHOD(ForEachAsync_ErrorCallbackInvokedOnFailure)
435 + {
436 + const std::vector<int> items = {1, 2, 3};
437 + std::vector<int> failedItems;
438 + std::vector<int> succeededItems;
439 +
440 + ForEachAsync<int>(
441 + items,
442 + [](int item) -> int {
443 + if (item == 2)
444 + {
445 + THROW_HR(E_FAIL);
446 + }
447 + return item;
448 + },
449 + [&](int result) { succeededItems.push_back(result); },
450 + [&](int item, wil::ResultException /*error*/) { failedItems.push_back(item); });
451 +
452 + VERIFY_ARE_EQUAL(1u, failedItems.size());
453 + VERIFY_ARE_EQUAL(2, failedItems[0]);
454 + VERIFY_ARE_EQUAL(2u, succeededItems.size());
455 + }
456 +
457 + TEST_METHOD(ForEachAsync_EmptyInputProducesNoCallbacks)
458 + {
459 + const std::vector<int> items;
460 + bool successCalled = false;
461 + bool errorCalled = false;
462 +
463 + ForEachAsync<int>(
464 + items,
465 + [](int item) { return item; },
466 + [&](int /*result*/) { successCalled = true; },
467 + [&](int /*item*/, wil::ResultException /*error*/) { errorCalled = true; });
468 +
469 + VERIFY_IS_FALSE(successCalled);
470 + VERIFY_IS_FALSE(errorCalled);
471 + }
472 +
473 + TEST_METHOD(ForEachAsync_BatchSizeOfOneProcessesAllItems)
474 + {
475 + const std::vector<int> items = {10, 20, 30, 40, 50};
476 + std::vector<int> results;
477 +
478 + ForEachAsync<int>(
479 + items,
480 + [](int item) { return item; },
481 + [&](int result) { results.push_back(result); },
482 + [](int /*item*/, wil::ResultException /*error*/) { VERIFY_FAIL(L"Unexpected error"); },
483 + /*batchSize=*/1);
484 +
485 + VERIFY_ARE_EQUAL(items.size(), results.size());
486 + for (int item : items)
487 + {
488 + VERIFY_IS_TRUE(std::find(results.begin(), results.end(), item) != results.end());
489 + }
490 + }
491 +
492 + TEST_METHOD(ForEachAsync_ErrorInOnErrorPropagatesThrow)
493 + {
494 + const std::vector<int> items = {1};
495 +
496 + VERIFY_THROWS_SPECIFIC(
497 + ForEachAsync<int>(
498 + items,
499 + [](int /*item*/) -> int { THROW_HR(E_ACCESSDENIED); },
500 + [](int /*result*/) {},
501 + [](int /*item*/, wil::ResultException error) { throw error; }),
502 + wil::ResultException,
503 + [](const wil::ResultException& ex) { return ex.GetErrorCode() == E_ACCESSDENIED; });
504 + }
505 +};
506 +
507 } // namespace WSLCCLIExecutionUnitTests