Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions windows/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public App()
services.AddSingleton<IOperationHistoryService, JsonOperationHistoryService>();
services.AddSingleton<IMoleEngineService, MoleEngineService>();
services.AddSingleton<ISafeDeletionService, RecycleBinDeletionService>();
services.AddSingleton<WindowsGpuPerformanceCounterProvider>();
services.AddSingleton<IGpuTelemetryProvider>(provider =>
new GpuTelemetryBackoffProvider(provider.GetRequiredService<WindowsGpuPerformanceCounterProvider>()));
services.AddSingleton<ISystemTelemetryService, WindowsSystemTelemetryService>();
services.AddSingleton<ISystemTelemetryHistoryService, JsonSystemTelemetryHistoryService>();
services.AddSingleton<SystemTelemetrySamplerService>();
Expand Down
27 changes: 27 additions & 0 deletions windows/Models/GpuTelemetrySample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Globalization;

namespace BurrowWin.Models;

public sealed record GpuTelemetrySample(
double? UsagePercent,
string Status,
string? UnavailableReason = null,
DateTimeOffset? RetryAfter = null)
{
public bool IsAvailable => UsagePercent.HasValue;

public static GpuTelemetrySample Available(double usagePercent)
{
var normalized = double.IsFinite(usagePercent)
? Math.Clamp(usagePercent, 0, 100)
: 0;
return new GpuTelemetrySample(
normalized,
string.Create(CultureInfo.InvariantCulture, $"3D {normalized:0.0}%"));
}

public static GpuTelemetrySample Unavailable(string reason)
{
return new GpuTelemetrySample(null, "Unavailable", reason);
}
}
58 changes: 57 additions & 1 deletion windows/Models/SystemTelemetrySnapshot.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json.Serialization;

namespace BurrowWin.Models;
Expand Down Expand Up @@ -30,6 +31,58 @@ public sealed record SystemTelemetrySnapshot(

public bool HasBattery { get; init; }

public double? GpuUsagePercent { get; init; }

public string? GpuUnavailableReason { get; init; }

[JsonIgnore]
public double? EffectiveGpuUsagePercent
{
get
{
if (GpuUsagePercent.HasValue)
{
return double.IsFinite(GpuUsagePercent.Value)
? Math.Clamp(GpuUsagePercent.Value, 0, 100)
: null;
}

if (string.IsNullOrWhiteSpace(GpuStatus) ||
string.Equals(GpuStatus, "Unavailable", StringComparison.OrdinalIgnoreCase))
{
return null;
}

var percentMarker = GpuStatus.LastIndexOf('%');
if (percentMarker < 0)
{
return null;
}

var numericEnd = percentMarker;
while (numericEnd > 0 && char.IsWhiteSpace(GpuStatus[numericEnd - 1]))
{
numericEnd--;
}

var numericStart = numericEnd;
while (numericStart > 0 &&
(char.IsDigit(GpuStatus[numericStart - 1]) ||
GpuStatus[numericStart - 1] is '.' or '-' or '+'))
{
numericStart--;
}

var numeric = GpuStatus[numericStart..numericEnd];
return double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
? Math.Clamp(parsed, 0, 100)
: null;
}
}

[JsonIgnore]
public bool IsGpuAvailable => EffectiveGpuUsagePercent.HasValue;

[JsonIgnore]
public string TimestampText => CapturedAt.ToLocalTime().ToString("HH:mm:ss");

Expand All @@ -56,6 +109,9 @@ public static SystemTelemetrySnapshot Empty(DateTimeOffset capturedAt)
0,
0,
"Unavailable",
[]);
[])
{
GpuUnavailableReason = "GPU telemetry has not been sampled."
};
}
}
107 changes: 107 additions & 0 deletions windows/Services/GpuTelemetryBackoffProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.ComponentModel;
using System.Security;
using BurrowWin.Models;

namespace BurrowWin.Services;

public sealed class GpuTelemetryBackoffProvider : IGpuTelemetryProvider
{
public static readonly TimeSpan DefaultInitialBackoff = TimeSpan.FromSeconds(30);
public static readonly TimeSpan DefaultMaximumBackoff = TimeSpan.FromMinutes(5);

private readonly IGpuTelemetryProvider _inner;
private readonly Func<DateTimeOffset> _utcNow;
private readonly TimeSpan _initialBackoff;
private readonly TimeSpan _maximumBackoff;
private readonly object _sync = new();

private int _consecutiveFailures;
private DateTimeOffset _nextAttemptAt = DateTimeOffset.MinValue;
private GpuTelemetrySample _lastUnavailable = GpuTelemetrySample.Unavailable("GPU telemetry has not been sampled yet.");

public GpuTelemetryBackoffProvider(IGpuTelemetryProvider inner)
: this(inner, () => DateTimeOffset.UtcNow, DefaultInitialBackoff, DefaultMaximumBackoff)
{
}

public GpuTelemetryBackoffProvider(
IGpuTelemetryProvider inner,
Func<DateTimeOffset> utcNow,
TimeSpan initialBackoff,
TimeSpan maximumBackoff)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(utcNow);

if (initialBackoff <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(initialBackoff));
}

if (maximumBackoff < initialBackoff)
{
throw new ArgumentOutOfRangeException(nameof(maximumBackoff));
}

_inner = inner;
_utcNow = utcNow;
_initialBackoff = initialBackoff;
_maximumBackoff = maximumBackoff;
}

public GpuTelemetrySample Capture()
{
lock (_sync)
{
var now = _utcNow();
if (now < _nextAttemptAt)
{
return _lastUnavailable with { RetryAfter = _nextAttemptAt };
}

GpuTelemetrySample sample;
try
{
sample = _inner.Capture();
}
catch (Exception ex) when (IsExpectedCounterFailure(ex))
{
sample = GpuTelemetrySample.Unavailable($"GPU performance counters are inaccessible ({ex.GetType().Name}).");
}

if (sample.IsAvailable)
{
_consecutiveFailures = 0;
_nextAttemptAt = DateTimeOffset.MinValue;
return sample with { RetryAfter = null };
}

_consecutiveFailures++;
_nextAttemptAt = now + CalculateBackoff(_consecutiveFailures);
_lastUnavailable = sample with { RetryAfter = _nextAttemptAt };
return _lastUnavailable;
}
}

private TimeSpan CalculateBackoff(int consecutiveFailures)
{
var delayTicks = _initialBackoff.Ticks;
for (var index = 1; index < consecutiveFailures && delayTicks < _maximumBackoff.Ticks; index++)
{
delayTicks = delayTicks > _maximumBackoff.Ticks / 2
? _maximumBackoff.Ticks
: delayTicks * 2;
}

return TimeSpan.FromTicks(delayTicks);
}

private static bool IsExpectedCounterFailure(Exception exception)
{
return exception is Win32Exception or
InvalidOperationException or
UnauthorizedAccessException or
PlatformNotSupportedException or
SecurityException;
}
}
8 changes: 8 additions & 0 deletions windows/Services/IGpuTelemetryProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using BurrowWin.Models;

namespace BurrowWin.Services;

public interface IGpuTelemetryProvider
{
GpuTelemetrySample Capture();
}
3 changes: 3 additions & 0 deletions windows/Services/LocalMcpServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,9 @@ private static JsonObject SnapshotToJson(SystemTelemetrySnapshot snapshot, strin
["network_received_bytes_per_second"] = snapshot.NetworkReceivedBytesPerSecond,
["network_sent_bytes_per_second"] = snapshot.NetworkSentBytesPerSecond,
["gpu_status"] = snapshot.GpuStatus,
["gpu_available"] = snapshot.IsGpuAvailable,
["gpu_usage_percent"] = snapshot.EffectiveGpuUsagePercent,
["gpu_unavailable_reason"] = snapshot.GpuUnavailableReason,
["has_battery"] = snapshot.HasBattery,
["battery_charge_percent"] = snapshot.BatteryChargePercent,
["battery_status"] = snapshot.BatteryStatusText,
Expand Down
7 changes: 7 additions & 0 deletions windows/Services/SystemTelemetryFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,11 @@ public static string DiskSummary(SystemTelemetrySnapshot snapshot)
{
return $"{Bytes(snapshot.DiskUsedBytes)} / {Bytes(snapshot.DiskTotalBytes)}";
}

public static string GpuMetric(SystemTelemetrySnapshot snapshot)
{
return snapshot.EffectiveGpuUsagePercent is { } usagePercent
? string.Create(CultureInfo.InvariantCulture, $"3D {usagePercent:0.0}%")
: "Unavailable";
}
}
66 changes: 66 additions & 0 deletions windows/Services/WindowsGpuPerformanceCounterProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Security;
using BurrowWin.Models;

namespace BurrowWin.Services;

public sealed class WindowsGpuPerformanceCounterProvider : IGpuTelemetryProvider
{
private const string CategoryName = "GPU Engine";
private const string CounterName = "Utilization Percentage";

public GpuTelemetrySample Capture()
{
try
{
if (!PerformanceCounterCategory.Exists(CategoryName))
{
return GpuTelemetrySample.Unavailable("The GPU Engine performance-counter category is unavailable.");
}

var category = new PerformanceCounterCategory(CategoryName);
var instanceNames = category.GetInstanceNames()
.Where(name => name.Contains("engtype_3D", StringComparison.OrdinalIgnoreCase))
.ToArray();

if (instanceNames.Length == 0)
{
return GpuTelemetrySample.Unavailable("No 3D GPU performance-counter instances are available.");
}

double total = 0;
var successfulReads = 0;
foreach (var instanceName in instanceNames)
{
try
{
using var counter = new PerformanceCounter(CategoryName, CounterName, instanceName, readOnly: true);
total += counter.NextValue();
successfulReads++;
}
catch (Exception ex) when (IsExpectedCounterFailure(ex))
{
// GPU engine instances can disappear while the category is enumerated.
}
}

return successfulReads == 0
? GpuTelemetrySample.Unavailable("GPU performance-counter instances could not be read.")
: GpuTelemetrySample.Available(total);
}
catch (Exception ex) when (IsExpectedCounterFailure(ex))
{
return GpuTelemetrySample.Unavailable($"GPU performance counters are inaccessible ({ex.GetType().Name}).");
}
}

private static bool IsExpectedCounterFailure(Exception exception)
{
return exception is Win32Exception or
InvalidOperationException or
UnauthorizedAccessException or
PlatformNotSupportedException or
SecurityException;
}
}
Loading
Loading