Add ContainerStats runtime implmentation (#40475)

David Bennett committed May 8, 2026 at 16:22 UTC 50b93bb06e5e15f48ca5ea37738936d41f28a364
7 files changed +232 -1
src/windows/inc/docker_schema.h
+76
@@ -552,4 +552,80 @@ struct CreateImageProgress
552 NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(CreateImageProgress, status, id, progressDetail, errorDetail);
553 };
554
555 +// Container stats (GET /containers/{id}/stats?stream=false)
556 +// See: https://docs.docker.com/reference/api/engine/version/v1.52/#tag/Container/operation/ContainerStats
557 +
558 +struct ContainerStatsCpuUsage
559 +{
560 + uint64_t total_usage{};
561 + std::optional<std::vector<uint64_t>> percpu_usage;
562 + uint64_t usage_in_kernelmode{};
563 + uint64_t usage_in_usermode{};
564 +
565 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsCpuUsage, total_usage, percpu_usage, usage_in_kernelmode, usage_in_usermode);
566 +};
567 +
568 +struct ContainerStatsCpuStats
569 +{
570 + ContainerStatsCpuUsage cpu_usage;
571 + uint64_t system_cpu_usage{};
572 + uint32_t online_cpus{};
573 +
574 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsCpuStats, cpu_usage, system_cpu_usage, online_cpus);
575 +};
576 +
577 +struct ContainerStatsMemoryStats
578 +{
579 + uint64_t usage{};
580 + uint64_t limit{};
581 +
582 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsMemoryStats, usage, limit);
583 +};
584 +
585 +struct ContainerStatsNetworkEntry
586 +{
587 + uint64_t rx_bytes{};
588 + uint64_t rx_packets{};
589 + uint64_t tx_bytes{};
590 + uint64_t tx_packets{};
591 +
592 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsNetworkEntry, rx_bytes, rx_packets, tx_bytes, tx_packets);
593 +};
594 +
595 +struct ContainerStatsBlkioEntry
596 +{
597 + std::string op;
598 + uint64_t value{};
599 +
600 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsBlkioEntry, op, value);
601 +};
602 +
603 +struct ContainerStatsBlkioStats
604 +{
605 + std::optional<std::vector<ContainerStatsBlkioEntry>> io_service_bytes_recursive;
606 +
607 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsBlkioStats, io_service_bytes_recursive);
608 +};
609 +
610 +struct ContainerStatsPidsStats
611 +{
612 + uint64_t current{};
613 +
614 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStatsPidsStats, current);
615 +};
616 +
617 +struct ContainerStats
618 +{
619 + std::string id;
620 + std::string name;
621 + ContainerStatsCpuStats cpu_stats;
622 + ContainerStatsCpuStats precpu_stats;
623 + ContainerStatsMemoryStats memory_stats;
624 + std::optional<std::map<std::string, ContainerStatsNetworkEntry>> networks;
625 + ContainerStatsBlkioStats blkio_stats;
626 + ContainerStatsPidsStats pids_stats;
627 +
628 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ContainerStats, id, name, cpu_stats, precpu_stats, memory_stats, networks, blkio_stats, pids_stats);
629 +};
630 +
631 } // namespace wsl::windows::common::docker_schema
src/windows/service/inc/wslc.idl
+1
@@ -527,6 +527,7 @@ interface IWSLCContainer : IUnknown
527 HRESULT GetName([out, string] LPSTR* Name);
528 HRESULT GetLabels([out, size_is(, *Count)] WSLCLabelInformation** Labels, [out] ULONG* Count);
529 HRESULT Kill([in] WSLCSignal Signal);
530 + HRESULT Stats([out] LPSTR* Output);
531 }
532
533 typedef enum _WSLCDeletedImageType
src/windows/wslcsession/DockerHTTPClient.cpp
+8
@@ -385,6 +385,14 @@ docker_schema::InspectContainer DockerHTTPClient::InspectContainer(const std::st
385 return Transaction<EmptyRequest, docker_schema::InspectContainer>(verb::get, URL::Create("/containers/{}/json", Id));
386 }
387
388 +docker_schema::ContainerStats DockerHTTPClient::ContainerStats(const std::string& Id)
389 +{
390 + auto url = URL::Create("/containers/{}/stats", Id);
391 + url.SetParameter("stream", false);
392 + url.SetParameter("one-shot", true);
393 + return Transaction<EmptyRequest, docker_schema::ContainerStats>(verb::get, url);
394 +}
395 +
396 docker_schema::InspectExec DockerHTTPClient::InspectExec(const std::string& Id)
397 {
398 return Transaction<EmptyRequest, docker_schema::InspectExec>(verb::get, URL::Create("/exec/{}/json", Id));
src/windows/wslcsession/DockerHTTPClient.h
+2 -1
@@ -136,6 +136,7 @@ public:
136 void DeleteContainer(const std::string& Id, bool Force, bool DeleteVolumes = false);
137 void SignalContainer(const std::string& Id, std::optional<WSLCSignal> Signal);
138 common::docker_schema::InspectContainer InspectContainer(const std::string& Id);
139 + common::docker_schema::ContainerStats ContainerStats(const std::string& Id);
140 common::docker_schema::InspectExec InspectExec(const std::string& Id);
141 wil::unique_socket AttachContainer(const std::string& Id, const std::optional<std::string>& DetachKeys);
142 void ResizeContainerTty(const std::string& Id, ULONG Rows, ULONG Columns);
@@ -291,4 +292,4 @@ private:
292 HANDLE m_exitingEvent;
293 wil::srwlock m_lock;
294 };
294 -} // namespace wsl::windows::service::wslc
\ No newline at end of file
295 +} // namespace wsl::windows::service::wslc
src/windows/wslcsession/WSLCContainer.cpp
+25
@@ -1732,6 +1732,19 @@ void WSLCContainerImpl::Logs(WSLCLogsFlags Flags, WSLCHandle* Stdout, WSLCHandle
1732 }
1733 }
1734
1735 +void WSLCContainerImpl::Stats(LPSTR* Output) const
1736 +{
1737 + auto lock = m_lock.lock_shared();
1738 +
1739 + try
1740 + {
1741 + auto stats = m_dockerClient.ContainerStats(m_id);
1742 + std::string json = wsl::shared::ToJson(stats);
1743 + *Output = wil::make_unique_ansistring<wil::unique_cotaskmem_ansistring>(json.c_str()).release();
1744 + }
1745 + CATCH_AND_THROW_DOCKER_USER_ERROR("Failed to get stats for container '%hs'", m_id.c_str());
1746 +}
1747 +
1748 std::unique_ptr<RelayedProcessIO> WSLCContainerImpl::CreateRelayedProcessIO(wil::unique_handle&& stream, WSLCProcessFlags flags)
1749 {
1750 // Create one pipe for each STD handle.
@@ -2021,6 +2034,18 @@ HRESULT WSLCContainer::Inspect(LPSTR* Output)
2034 return CallImpl(&WSLCContainerImpl::Inspect, Output);
2035 }
2036
2037 +HRESULT WSLCContainer::Stats(LPSTR* Output)
2038 +try
2039 +{
2040 + COMServiceExecutionContext context;
2041 +
2042 + RETURN_HR_IF(E_POINTER, Output == nullptr);
2043 +
2044 + *Output = nullptr;
2045 + return CallImpl(&WSLCContainerImpl::Stats, Output);
2046 +}
2047 +CATCH_RETURN();
2048 +
2049 HRESULT WSLCContainer::Delete(WSLCDeleteFlags Flags)
2050 try
2051 {
src/windows/wslcsession/WSLCContainer.h
+2
@@ -102,6 +102,7 @@ public:
102 void Exec(_In_ const WSLCProcessOptions* Options, LPCSTR DetachKeys, _Out_ IWSLCProcess** Process);
103 void Inspect(LPSTR* Output) const;
104 void Logs(WSLCLogsFlags Flags, WSLCHandle* Stdout, WSLCHandle* Stderr, ULONGLONG Since, ULONGLONG Until, ULONGLONG Tail) const;
105 + void Stats(LPSTR* Output) const;
106 void GetLabels(WSLCLabelInformation** Labels, ULONG* Count) const;
107
108 void CopyTo(IWSLCContainer** Container) const;
@@ -228,6 +229,7 @@ public:
229 IFACEMETHOD(GetId)(_Out_ WSLCContainerId Id) override;
230 IFACEMETHOD(GetName)(_Out_ LPSTR* Name) override;
231 IFACEMETHOD(GetLabels)(_Out_ WSLCLabelInformation** Labels, _Out_ ULONG* Count) override;
232 + IFACEMETHOD(Stats)(_Out_ LPSTR* Output) override;
233
234 IFACEMETHOD(InterfaceSupportsErrorInfo)(REFIID riid);
235
test/windows/WSLCTests.cpp
+118
@@ -7797,6 +7797,124 @@ class WSLCTests
7797 }
7798 }
7799
7800 + WSLC_TEST_METHOD(ContainerStats_RunningContainer)
7801 + {
7802 + // Start a long-lived detached container on a bridged network so network stats are populated.
7803 + WSLCContainerLauncher launcher("debian:latest", "wslc-test-stats", {"sleep", "60"}, {}, WSLCContainerNetworkTypeBridged);
7804 +
7805 + auto runningContainer = launcher.Launch(*m_defaultSession, WSLCContainerStartFlagsNone);
7806 + auto cleanup = wil::scope_exit([&]() { runningContainer.SetDeleteOnClose(true); });
7807 +
7808 + wil::com_ptr<IWSLCContainer> container;
7809 + VERIFY_SUCCEEDED(m_defaultSession->OpenContainer("wslc-test-stats", &container));
7810 +
7811 + wil::unique_cotaskmem_ansistring output;
7812 + VERIFY_SUCCEEDED(container->Stats(&output));
7813 + VERIFY_IS_NOT_NULL(output.get());
7814 + VERIFY_IS_FALSE(std::string(output.get()).empty());
7815 +
7816 + const auto stats = wsl::shared::FromJson<wsl::windows::common::docker_schema::ContainerStats>(output.get());
7817 +
7818 + // cpu_stats
7819 + // The VM has been running so system_cpu_usage is non-zero.
7820 + VERIFY_IS_GREATER_THAN(stats.cpu_stats.system_cpu_usage, 0ull);
7821 +
7822 + // The container process itself has consumed some CPU.
7823 + VERIFY_IS_GREATER_THAN(stats.cpu_stats.cpu_usage.total_usage, 0ull);
7824 +
7825 + // Kernel + user time together must not exceed total CPU time.
7826 + VERIFY_IS_LESS_THAN_OR_EQUAL(
7827 + stats.cpu_stats.cpu_usage.usage_in_kernelmode + stats.cpu_stats.cpu_usage.usage_in_usermode, stats.cpu_stats.cpu_usage.total_usage);
7828 +
7829 + // The session was created with 4 CPUs.
7830 + VERIFY_IS_GREATER_THAN(stats.cpu_stats.online_cpus, 0u);
7831 +
7832 + // precpu_stats
7833 + // precpu_stats is a prior snapshot; its total must not exceed the current total.
7834 + VERIFY_IS_LESS_THAN_OR_EQUAL(stats.precpu_stats.cpu_usage.total_usage, stats.cpu_stats.cpu_usage.total_usage);
7835 + VERIFY_IS_LESS_THAN_OR_EQUAL(stats.precpu_stats.system_cpu_usage, stats.cpu_stats.system_cpu_usage);
7836 +
7837 + // memory_stats
7838 + // Limit is the VM memory ceiling — must be non-zero.
7839 + VERIFY_IS_GREATER_THAN(stats.memory_stats.limit, 0ull);
7840 +
7841 + // The sleep process occupies at least some memory.
7842 + VERIFY_IS_GREATER_THAN(stats.memory_stats.usage, 0ull);
7843 +
7844 + // Usage must never exceed the reported limit.
7845 + VERIFY_IS_LESS_THAN_OR_EQUAL(stats.memory_stats.usage, stats.memory_stats.limit);
7846 +
7847 + // pids_stats
7848 + // At minimum the sleep process itself must be counted.
7849 + VERIFY_IS_GREATER_THAN(stats.pids_stats.current, 0ull);
7850 +
7851 + // networks
7852 + // A bridged container always has at least one network interface.
7853 + VERIFY_IS_TRUE(stats.networks.has_value());
7854 + VERIFY_IS_FALSE(stats.networks->empty());
7855 +
7856 + // Every interface entry must have consistent packet/byte counts
7857 + // (bytes >= 0 is trivially true for unsigned, but packets imply bytes >= 0 too).
7858 + for (const auto& [iface, net] : *stats.networks)
7859 + {
7860 + VERIFY_IS_FALSE(iface.empty());
7861 +
7862 + // If packets were received/sent, the byte count must also be non-zero.
7863 + if (net.rx_packets > 0)
7864 + {
7865 + VERIFY_IS_GREATER_THAN(net.rx_bytes, 0ull);
7866 + }
7867 + if (net.tx_packets > 0)
7868 + {
7869 + VERIFY_IS_GREATER_THAN(net.tx_bytes, 0ull);
7870 + }
7871 + }
7872 +
7873 + // blkio_stats
7874 + // io_service_bytes_recursive may be absent for a container with no disk I/O,
7875 + // but if present every entry must have a non-empty operation name.
7876 + if (stats.blkio_stats.io_service_bytes_recursive.has_value())
7877 + {
7878 + for (const auto& entry : *stats.blkio_stats.io_service_bytes_recursive)
7879 + {
7880 + VERIFY_IS_FALSE(entry.op.empty());
7881 + }
7882 + }
7883 + }
7884 +
7885 + WSLC_TEST_METHOD(ContainerStats_NullOutputPointer)
7886 + {
7887 + WSLCContainerLauncher launcher("debian:latest", "wslc-test-stats-null", {"sleep", "60"}, {}, WSLCContainerNetworkTypeBridged);
7888 + auto runningContainer = launcher.Launch(*m_defaultSession, WSLCContainerStartFlagsNone);
7889 + auto cleanup = wil::scope_exit([&]() { runningContainer.SetDeleteOnClose(true); });
7890 +
7891 + wil::com_ptr<IWSLCContainer> container;
7892 + VERIFY_SUCCEEDED(m_defaultSession->OpenContainer("wslc-test-stats-null", &container));
7893 +
7894 + // Passing nullptr for Output must fail.
7895 + VERIFY_FAILED(container->Stats(nullptr));
7896 + }
7897 +
7898 + WSLC_TEST_METHOD(ContainerStats_CreatedContainer_ReturnsZeroedStats)
7899 + {
7900 + // A created-but-not-started container returns zeroed stats from Docker rather than an error.
7901 + WSLCContainerLauncher launcher("debian:latest", "wslc-test-stats-created", {}, {}, WSLCContainerNetworkTypeBridged);
7902 + auto [result, runningContainer] = launcher.CreateNoThrow(*m_defaultSession);
7903 + VERIFY_SUCCEEDED(result);
7904 +
7905 + wil::com_ptr<IWSLCContainer> container;
7906 + VERIFY_SUCCEEDED(m_defaultSession->OpenContainer("wslc-test-stats-created", &container));
7907 + auto cleanup = wil::scope_exit([&]() { LOG_IF_FAILED(container->Delete(WSLCDeleteFlagsForce)); });
7908 +
7909 + wil::unique_cotaskmem_ansistring output;
7910 + VERIFY_SUCCEEDED(container->Stats(&output));
7911 + VERIFY_IS_NOT_NULL(output.get());
7912 +
7913 + // A non-running container has no active processes.
7914 + auto stats = wsl::shared::FromJson<wsl::windows::common::docker_schema::ContainerStats>(output.get());
7915 + VERIFY_ARE_EQUAL(0ull, stats.pids_stats.current);
7916 + }
7917 +
7918 WSLC_TEST_METHOD(InvalidNames)
7919 {
7920 auto expectInvalidArg = [&](const std::string& name) {