< Summary

Information
Class: AmbientServices.StandardCpuSampler
Assembly: AmbientServices
File(s): /home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/CpuMonitor.cs
Tag: 332_35464845198
Line coverage
0%
Covered lines: 0
Uncovered lines: 10
Coverable lines: 10
Total lines: 579
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%210%
Sample()100%210%
GetUsage()100%210%
GetPendingUsage()100%210%

File(s)

/home/runner/work/AmbientServices/AmbientServices/AmbientServices/Helpers/CpuMonitor.cs

#LineLine coverage
 1using System;
 2using System.Diagnostics;
 3using System.IO;
 4using System.Runtime.InteropServices;
 5#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP1_0_OR_GREATER
 6using System.Threading.Tasks;
 7#endif
 8
 9namespace AmbientServices;
 10
 11/// <summary>
 12/// An interface that can be used to mock recent CPU usage so that code branches depending on CPU utilization can be tes
 13/// </summary>
 14/// <remarks>
 15/// <pitch>The test seam for CPU-dependent logic: register an implementation as the ambient service and <see cref="CpuMo
 16/// <pledge><see cref="RecentUsage"/> returns the value to report as the most recent CPU usage, between 0.0 and 1.0; it 
 17/// </remarks>
 18public interface IMockCpuUsage
 19{
 20    /// <summary>
 21    /// Gets the value to use as the most recent CPU usage, which should be a number between 0.0 and 1.0.
 22    /// </summary>
 23    float RecentUsage { get; }
 24}
 25
 26/// <summary>
 27/// An interface for CPU usage samplers.
 28/// </summary>
 29/// <remarks>
 30/// <pitch>The environment-specific strategy behind <see cref="CpuMonitor"/> — one realization per way of measuring CPU 
 31/// <pledge><c>Sample</c> is called periodically to close a measurement window; <c>GetUsage</c> returns the utilization 
 32/// </remarks>
 33internal interface ICpuSampler
 34{
 35    void Sample();
 36    float GetUsage();
 37    float GetPendingUsage();
 38}
 39
 40/// <summary>
 41/// A class that monitors process CPU utilization.
 42/// </summary>
 43/// <remarks>
 44/// <pitch>Continuous, low-overhead CPU utilization for this process — a windowed average suitable for throttling decisi
 45/// <pledge>
 46/// <see cref="RecentUsage"/> reports the average utilization (0.0–1.0, across all processors or the container quota) ov
 47/// When an ambient <see cref="IMockCpuUsage"/> is registered, <see cref="RecentUsage"/> returns its value instead of a 
 48/// </pledge>
 49/// <plan>
 50/// An <see cref="AmbientEventTimer"/> fires at the construction-time window size (default 250ms) and tells the sampler 
 51/// </plan>
 52/// <priority>
 53/// 1. What the process is actually allowed to use over what the machine has: on Linux the sampler reads the cgroup quot
 54/// 2. Sampling cost independent of readers over current readings: one sample per window regardless of how many callers 
 55/// 3. A windowed average over an instantaneous figure: throttling on an instantaneous reading oscillates, so the report
 56/// </priority>
 57/// </remarks>
 58public sealed class CpuMonitor : IDisposable
 59#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP1_0_OR_GREATER
 60    , IAsyncDisposable
 61#endif
 62{
 63    private static readonly AmbientService<IMockCpuUsage> _MockCpu = Ambient.GetService<IMockCpuUsage>();
 64    private readonly AmbientEventTimer _timer = new();
 65    private readonly ICpuSampler _sampler;
 66
 67    /// <summary>
 68    /// Constructs a system CPU usage monitor.
 69    /// </summary>
 70    /// <param name="windowMilliseconds">The number of milliseconds between samples.</param>
 71    public CpuMonitor(long windowMilliseconds = 250)
 72    {
 73        _sampler = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
 74            ? new LinuxContainerCpuSampler()
 75            : new StandardCpuSampler();
 76        _timer.AutoReset = true;
 77        _timer.Interval = windowMilliseconds;
 78        _timer.Enabled = true;
 79        _timer.Elapsed += (s, e) => _sampler.Sample();
 80        _sampler.Sample();
 81    }
 82    /// <summary>
 83    /// Constructs a system CPU usage monitor.
 84    /// </summary>
 85    /// <param name="minimumWindow">A <see cref="TimeSpan"/> indicating the minimum sampling window size.</param>
 86    public CpuMonitor(TimeSpan minimumWindow) : this((long)minimumWindow.TotalMilliseconds)
 87    {
 88    }
 89
 90    /// <summary>
 91    /// Gets the proportion of time the CPU was in use (average across all CPUs) in the previous measurement window, whi
 92    /// </summary>
 93    public float RecentUsage => _MockCpu.Local?.RecentUsage ?? _sampler.GetUsage();
 94
 95    /// <summary>
 96    /// Gets the proportion of time the CPU was in use (average across all CPUs) since the last sample was taken.
 97    /// </summary>
 98    public float PendingUsage => _sampler.GetPendingUsage();
 99
 100    /// <summary>
 101    /// Disposes of the CPU monitor.
 102    /// </summary>
 103    public void Dispose()
 104    {
 105        _timer.Enabled = false;
 106        _timer.Dispose();
 107    }
 108#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP1_0_OR_GREATER
 109    /// <summary>
 110    /// Disposes of the CPU monitor.
 111    /// </summary>
 112    /// <returns></returns>
 113    public ValueTask DisposeAsync()
 114    {
 115        _timer.Enabled = false;
 116        _timer.Dispose();
 117#if NETCOREAPP1_0_OR_GREATER
 118        return ValueTask.CompletedTask;
 119#else
 120        return default;
 121#endif
 122    }
 123#endif
 124}
 125
 126/// <summary>
 127/// A class that represents a single sample of CPU usage.
 128/// Two samples can be compared to see how much CPU the process used between the time the first sample was taken and the
 129/// </summary>
 130/// <remarks>
 131/// <pitch>An immutable point-in-time pairing of wall-clock and process-CPU timestamps; utilization is only meaningful a
 132/// <pledge><see cref="CpuUtilization"/> of two samples yields the process's average utilization across all processors o
 133/// </remarks>
 134internal readonly struct CpuSample : IEquatable<CpuSample>
 135{
 136    /// <summary>
 137    /// Gets the current <see cref="Process"/>.
 138    /// </summary>
 139    /// <remarks>Note that when you want CPU usage time, this *cannot* be cached--it must be called each time.</remarks>
 140    /// <returns>The current <see cref="Process"/>, if available, or null if not available.</returns>
 141    private static Process? GetCurrentProcess() =>
 142#if NET5_0_OR_GREATER
 143        OperatingSystem.IsBrowser() ? null :
 144#endif
 145        Process.GetCurrentProcess();
 146
 147    private readonly long _wallClockTicks;
 148    private readonly long _processTicks;
 149
 150    private CpuSample(long wallClockTicks = 0, long processTicks = 0)
 151    {
 152        _wallClockTicks = wallClockTicks;
 153        _processTicks = processTicks;
 154    }
 155    /// <summary>
 156    /// Checks if this sample is equal to another object.
 157    /// </summary>
 158    /// <param name="other">The other CPU usage sample.</param>
 159    /// <returns>true if the objects are logically equal, otherwise false.</returns>
 160    public bool Equals(CpuSample other)
 161    {
 162        return _wallClockTicks == other._wallClockTicks && _processTicks == other._processTicks;
 163    }
 164    /// <summary>
 165    /// Checks if this sample is equal to another object.
 166    /// </summary>
 167    /// <param name="obj">The other object.</param>
 168    /// <returns>true if the objects are logically equal, otherwise false.</returns>
 169    public override bool Equals(object? obj)
 170    {
 171        return (obj is CpuSample other) && Equals(other);
 172    }
 173    /// <summary>
 174    /// Gets a hash code for this sample.
 175    /// </summary>
 176    /// <returns>The hash code.</returns>
 177    public override int GetHashCode()
 178    {
 179        return _wallClockTicks.GetHashCode() ^ _processTicks.GetHashCode();
 180    }
 181    /// <summary>
 182    /// Checks if two samples are equal.
 183    /// </summary>
 184    /// <param name="left">The left sample.</param>
 185    /// <param name="right">The right sample.</param>
 186    /// <returns>true if the samples are logically equal, false if they are not.</returns>
 187    public static bool operator ==(CpuSample left, CpuSample right)
 188    {
 189        return left.Equals(right);
 190    }
 191    /// <summary>
 192    /// Checks if two samples are unequal.
 193    /// </summary>
 194    /// <param name="left">The left sample.</param>
 195    /// <param name="right">The right sample.</param>
 196    /// <returns>true if the samples are logically unequal, false if they are logically equal.</returns>
 197    public static bool operator !=(CpuSample left, CpuSample right)
 198    {
 199        return !(left == right);
 200    }
 201    /// <summary>
 202    /// Computes the CPU utilization between the two specified samples.
 203    /// </summary>
 204    /// <param name="first">The first sample.</param>
 205    /// <param name="second">The second sample.</param>
 206    /// <returns>The average CPU utilization (between 0.0 and 1.0) for the calling process between the time <paramref na
 207    public static float CpuUtilization(CpuSample first, CpuSample second)
 208    {
 209        long wallTicks = second._wallClockTicks - first._wallClockTicks;
 210        long cpuTicks = second._processTicks - first._processTicks;
 211        return Math.Min(1.0f, Math.Max(0.0f, (cpuTicks * 1.0f) / wallTicks / Environment.ProcessorCount));
 212    }
 213    /// <summary>
 214    /// Samples the current CPU state for the process.
 215    /// </summary>
 216    /// <returns>A <see cref="CpuSample"/> containing the state.</returns>
 217    public static CpuSample GetSample()
 218    {
 219        return new(Stopwatch.GetTimestamp(),
 220#if NET5_0_OR_GREATER
 221            OperatingSystem.IsBrowser() ? 0 :
 222#endif
 223            GetCurrentProcess()?.TotalProcessorTime.Ticks ?? 0);
 224    }
 225}
 226
 227/// <summary>
 228/// The default <see cref="ICpuSampler"/>, measuring process CPU time against wall-clock time.
 229/// </summary>
 230/// <remarks>
 231/// <pitch>The sampler for ordinary (non-cgroup-limited) environments: accurate process utilization relative to the whol
 232/// <pledge><see cref="ICpuSampler"/></pledge>
 233/// <plan>Keeps the last <see cref="CpuSample"/> and computes utilization as the CPU-time delta over the wall-clock delt
 234/// </remarks>
 235internal sealed class StandardCpuSampler : ICpuSampler
 236{
 237    private CpuSample _lastSample;
 238    private float _lastUsagePercent;
 239
 0240    public StandardCpuSampler()
 241    {
 0242        _lastSample = CpuSample.GetSample();
 0243    }
 244
 245    public void Sample()
 246    {
 0247        CpuSample newSample = CpuSample.GetSample();
 0248        _lastUsagePercent = CpuSample.CpuUtilization(_lastSample, newSample);
 0249        _lastSample = newSample;
 0250    }
 251
 0252    public float GetUsage() => _lastUsagePercent;
 253
 254    public float GetPendingUsage()
 255    {
 0256        CpuSample currentSample = CpuSample.GetSample();
 0257        return CpuSample.CpuUtilization(_lastSample, currentSample);
 258    }
 259}
 260
 261/// <summary>
 262/// An <see cref="ICpuSampler"/> that measures CPU usage against the Linux cgroup CPU quota, so containerized processes 
 263/// </summary>
 264/// <remarks>
 265/// <pitch>The sampler for Linux containers: a pod limited to half a CPU reads 100% when it uses its whole allowance, in
 266/// <pledge><see cref="ICpuSampler"/></pledge>
 267/// <pledge>When cgroup usage or quota information is unavailable (no quota set, files missing, or parse failures), usag
 268/// <plan>
 269/// At construction it discovers the cgroup layout once: detects v2 versus v1 (presence of <c>cgroup.controllers</c>), e
 270/// Each sample reads cumulative CPU nanoseconds and computes utilization as the usage delta over (quota-fraction × elap
 271/// </plan>
 272/// </remarks>
 273internal sealed class LinuxContainerCpuSampler : ICpuSampler
 274{
 275    private static readonly char[] SlashCharacterArray = ['/'];
 276
 277    private long? _lastUsage;
 278    private DateTime? _lastSampleTime;
 279    private float _lastUsagePercent;
 280
 281    private readonly string? _cpuUsagePath;
 282    private readonly string? _cpuQuotaPath;
 283    private readonly string? _cpuPeriodPath;
 284    private readonly bool _isCgroupV2;
 285
 286    /// <summary>
 287    /// Constructs a cgroup-based CPU sampler. For production on Linux, use the parameterless form so paths resolve to r
 288    /// </summary>
 289    /// <param name="cgroupFilesystemRoot">Optional directory that mirrors the root filesystem layout (e.g. contains <c>
 290    internal LinuxContainerCpuSampler(string? cgroupFilesystemRoot = null)
 291    {
 292        (_cpuUsagePath, _cpuQuotaPath, _cpuPeriodPath, _isCgroupV2, _) = DiscoverCgroupPaths(cgroupFilesystemRoot);
 293    }
 294
 295    public void Sample()
 296    {
 297        long? usage = GetCgroupCpuUsage();
 298        double? limit = GetCgroupCpuLimit();
 299        DateTime now = AmbientClock.UtcNow;
 300
 301        if (usage == null || limit == null)
 302        {
 303            _lastUsagePercent = 0f;
 304            return;
 305        }
 306
 307        if (_lastUsage == null || _lastSampleTime == null)
 308        {
 309            _lastUsage = usage;
 310            _lastSampleTime = now;
 311            _lastUsagePercent = 0f;
 312            return;
 313        }
 314
 315        long usageDelta = usage.Value - _lastUsage.Value;
 316        double timeDelta = (now - _lastSampleTime.Value).TotalSeconds;
 317        _lastUsage = usage;
 318        _lastSampleTime = now;
 319
 320        if (timeDelta <= 0)
 321        {
 322            _lastUsagePercent = 0f;
 323            return;
 324        }
 325
 326        double cpuSeconds = usageDelta / 1_000_000_000.0;
 327        double percent = cpuSeconds / (limit.Value * timeDelta);
 328        _lastUsagePercent = (float)Math.Min(Math.Max(percent, 0.0), 1.0);
 329    }
 330
 331    public float GetUsage() => _lastUsagePercent;
 332
 333    public float GetPendingUsage()
 334    {
 335        long? usage = GetCgroupCpuUsage();
 336        double? limit = GetCgroupCpuLimit();
 337        DateTime now = AmbientClock.UtcNow;
 338
 339        if (usage == null || limit == null || _lastUsage == null || _lastSampleTime == null)
 340            return 0f;
 341
 342        long usageDelta = usage.Value - _lastUsage.Value;
 343        double timeDelta = (now - _lastSampleTime.Value).TotalSeconds;
 344
 345        if (timeDelta <= 0)
 346            return 0f;
 347
 348        double cpuSeconds = usageDelta / 1_000_000_000.0;
 349        double percent = cpuSeconds / (limit.Value * timeDelta);
 350        return (float)Math.Min(Math.Max(percent, 0.0), 1.0);
 351    }
 352
 353    private long? GetCgroupCpuUsage()
 354    {
 355        if (_cpuUsagePath == null) return null;
 356
 357        long? ret = null;
 358        try
 359        {
 360            if (_isCgroupV2)
 361            {
 362                // cgroup v2 format: read from cpu.stat
 363                string[] lines = File.ReadAllLines(_cpuUsagePath);
 364                foreach (string line in lines)
 365                {
 366                    if (line.StartsWith("usage_usec ", StringComparison.Ordinal))
 367                    {
 368                        string usageStr = line.Substring("usage_usec ".Length);
 369                        if (long.TryParse(usageStr, out long usageUsec))
 370                        {
 371                            return usageUsec * 1000; // Convert to nanoseconds
 372                        }
 373                    }
 374                }
 375            }
 376            else
 377            {
 378                // cgroup v1 format: direct file read
 379                ret = long.Parse(File.ReadAllText(_cpuUsagePath), System.Globalization.CultureInfo.InvariantCulture);
 380            }
 381        }
 382        catch { }
 383
 384        return ret;
 385    }
 386
 387    private double? GetCgroupCpuLimit()
 388    {
 389        if (_cpuQuotaPath == null || _cpuPeriodPath == null) return null;
 390
 391        double? ret = null;
 392        try
 393        {
 394            if (_isCgroupV2)
 395            {
 396                // cgroup v2 format: read from cpu.max
 397                string content = File.ReadAllText(_cpuQuotaPath).Trim();
 398                string[] parts = content.Split(' ');
 399                if (parts.Length == 2 && long.TryParse(parts[0], out long quota) && long.TryParse(parts[1], out long per
 400                {
 401                    if (quota > 0 && period > 0) ret = (double)quota / period;
 402                }
 403            }
 404            else
 405            {
 406                // cgroup v1 format: read from separate quota and period files
 407                long quota = long.Parse(File.ReadAllText(_cpuQuotaPath), System.Globalization.CultureInfo.InvariantCultu
 408                long period = long.Parse(File.ReadAllText(_cpuPeriodPath), System.Globalization.CultureInfo.InvariantCul
 409                if (quota > 0 && period > 0) ret = (double)quota / period;
 410            }
 411        }
 412        catch { }
 413
 414        return ret;
 415    }
 416
 417    private static string ResolvePath(string? rootPrefix, string absoluteUnixPath)
 418    {
 419        if (rootPrefix == null || string.IsNullOrEmpty(rootPrefix)) return absoluteUnixPath;
 420        string trimmed = absoluteUnixPath.TrimStart('/');
 421        if (trimmed.Length == 0) return Path.GetFullPath(rootPrefix);
 422        string combined = rootPrefix;
 423        foreach (string segment in trimmed.Split(SlashCharacterArray, StringSplitOptions.RemoveEmptyEntries))
 424        {
 425            combined = Path.Combine(combined, segment);
 426        }
 427        return Path.GetFullPath(combined);
 428    }
 429
 430    private static (string? cpuUsagePath, string? cpuQuotaPath, string? cpuPeriodPath, bool isCgroupV2, string? containe
 431    {
 432        string? containerId = GetContainerId(cgroupFilesystemRoot);
 433        bool isCgroupV2 = IsCgroupV2(cgroupFilesystemRoot);
 434
 435        if (isCgroupV2)
 436        {
 437            return DiscoverCgroupV2Paths(containerId, cgroupFilesystemRoot);
 438        }
 439        else
 440        {
 441            return DiscoverCgroupV1Paths(containerId, cgroupFilesystemRoot);
 442        }
 443    }
 444
 445    private static string? GetContainerId(string? cgroupFilesystemRoot)
 446    {
 447        try
 448        {
 449            // Read container ID from /proc/self/cgroup
 450            string[] lines = File.ReadAllLines(ResolvePath(cgroupFilesystemRoot, "/proc/self/cgroup"));
 451            foreach (string line in lines)
 452            {
 453                // Look for docker container ID in the path
 454#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER
 455                if (line.Contains("docker", StringComparison.Ordinal))
 456#else
 457                if (line.Contains("docker"))
 458#endif
 459                {
 460                    string[] parts = line.Split(':');
 461                    if (parts.Length >= 3)
 462                    {
 463                        string path = parts[2];
 464                        // Extract container ID from path like "docker/1234567890abcdef"
 465#if NETSTANDARD2_0_OR_GREATER || NETCOREAPP || NET5_0_OR_GREATER
 466                        int dockerIndex = path.IndexOf("/docker/", StringComparison.Ordinal);
 467#else
 468                        int dockerIndex = path.IndexOf("/docker/");
 469#endif
 470                        if (dockerIndex >= 0)
 471                        {
 472                            string containerPart = path.Substring(dockerIndex + 8); // Skip "/docker/"
 473                            // Container ID is typically 64 characters, but can be shorter
 474#if NETSTANDARD2_1 || NETCOREAPP || NET5_0_OR_GREATER
 475                            int slashIndex = containerPart.IndexOf('/', StringComparison.Ordinal);
 476#else
 477                            int slashIndex = containerPart.IndexOf('/');
 478#endif
 479                            if (slashIndex > 0) return containerPart.Substring(0, slashIndex);
 480                            return containerPart;
 481                        }
 482                    }
 483                }
 484            }
 485        }
 486        catch { }
 487
 488        return null;
 489    }
 490
 491    private static bool IsCgroupV2(string? cgroupFilesystemRoot)
 492    {
 493        // Check if cgroup v2 is mounted
 494        return File.Exists(ResolvePath(cgroupFilesystemRoot, "/sys/fs/cgroup/cgroup.controllers"));
 495    }
 496
 497    private static (string? cpuUsagePath, string? cpuQuotaPath, string? cpuPeriodPath, bool isCgroupV2, string? containe
 498    {
 499        string? cpuUsagePath = null;
 500        string? cpuQuotaPath = null;
 501        string? cpuPeriodPath = null;
 502
 503        // Try different possible paths for cgroup v2
 504        string[] possibleBasePaths = {
 505            "/sys/fs/cgroup",
 506            $"/sys/fs/cgroup/docker/{containerId}",
 507            $"/sys/fs/cgroup/system.slice/docker-{containerId}.scope"
 508        };
 509
 510        foreach (string basePath in possibleBasePaths)
 511        {
 512            string resolvedBase = ResolvePath(cgroupFilesystemRoot, basePath);
 513            if (Directory.Exists(resolvedBase))
 514            {
 515                string cpuStatPath = Path.Combine(resolvedBase, "cpu.stat");
 516                string cpuMaxPath = Path.Combine(resolvedBase, "cpu.max");
 517
 518                if (File.Exists(cpuStatPath) && File.Exists(cpuMaxPath))
 519                {
 520                    cpuUsagePath = cpuStatPath;
 521                    cpuQuotaPath = cpuMaxPath;
 522                    cpuPeriodPath = cpuMaxPath; // Same file for v2
 523                    break;
 524                }
 525            }
 526        }
 527
 528        return (cpuUsagePath, cpuQuotaPath, cpuPeriodPath, true, containerId);
 529    }
 530
 531    private static (string? cpuUsagePath, string? cpuQuotaPath, string? cpuPeriodPath, bool isCgroupV2, string? containe
 532    {
 533        string? cpuUsagePath = null;
 534        string? cpuQuotaPath = null;
 535        string? cpuPeriodPath = null;
 536
 537        // Try different possible paths for cgroup v1
 538        string[] possibleBasePaths = {
 539            "/sys/fs/cgroup/cpuacct",
 540            $"/sys/fs/cgroup/cpuacct/docker/{containerId}",
 541            $"/sys/fs/cgroup/cpuacct/system.slice/docker-{containerId}.scope"
 542        };
 543
 544        string[] possibleCpuBasePaths = {
 545            "/sys/fs/cgroup/cpu",
 546            $"/sys/fs/cgroup/cpu/docker/{containerId}",
 547            $"/sys/fs/cgroup/cpu/system.slice/docker-{containerId}.scope"
 548        };
 549
 550        // Find CPU usage path
 551        foreach (string basePath in possibleBasePaths)
 552        {
 553            string resolvedBase = ResolvePath(cgroupFilesystemRoot, basePath);
 554            string usagePath = Path.Combine(resolvedBase, "cpuacct.usage");
 555            if (File.Exists(usagePath))
 556            {
 557                cpuUsagePath = usagePath;
 558                break;
 559            }
 560        }
 561
 562        // Find CPU quota and period paths
 563        foreach (string basePath in possibleCpuBasePaths)
 564        {
 565            string resolvedBase = ResolvePath(cgroupFilesystemRoot, basePath);
 566            string quotaPath = Path.Combine(resolvedBase, "cpu.cfs_quota_us");
 567            string periodPath = Path.Combine(resolvedBase, "cpu.cfs_period_us");
 568
 569            if (File.Exists(quotaPath) && File.Exists(periodPath))
 570            {
 571                cpuQuotaPath = quotaPath;
 572                cpuPeriodPath = periodPath;
 573                break;
 574            }
 575        }
 576
 577        return (cpuUsagePath, cpuQuotaPath, cpuPeriodPath, false, containerId);
 578    }
 579}