From 21e384f2c48d95c7eae3d223b5d39859195fe426 Mon Sep 17 00:00:00 2001 From: Ltcc0 <158467799+Ltcc0@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:56:58 +0800 Subject: [PATCH 1/3] Fix Windows GPU telemetry crash (BUR-11) --- windows/App.xaml.cs | 3 + windows/Models/GpuTelemetrySample.cs | 27 ++++ windows/Models/SystemTelemetrySnapshot.cs | 40 +++++- .../Services/GpuTelemetryBackoffProvider.cs | 107 ++++++++++++++ windows/Services/IGpuTelemetryProvider.cs | 8 ++ windows/Services/LocalMcpServerService.cs | 3 + windows/Services/SystemTelemetryFormatter.cs | 7 + .../WindowsGpuPerformanceCounterProvider.cs | 66 +++++++++ .../Services/WindowsSystemTelemetryService.cs | 57 ++------ .../BurrowWin.Tests/BurrowWin.Tests.csproj | 3 + .../GpuTelemetryBackoffProviderTests.cs | 136 ++++++++++++++++++ .../SystemTelemetryFormatterTests.cs | 27 ++++ windows/ViewModels/DashboardViewModel.cs | 35 +++-- 13 files changed, 464 insertions(+), 55 deletions(-) create mode 100644 windows/Models/GpuTelemetrySample.cs create mode 100644 windows/Services/GpuTelemetryBackoffProvider.cs create mode 100644 windows/Services/IGpuTelemetryProvider.cs create mode 100644 windows/Services/WindowsGpuPerformanceCounterProvider.cs create mode 100644 windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs diff --git a/windows/App.xaml.cs b/windows/App.xaml.cs index 8ad082ad..d63ecf2f 100644 --- a/windows/App.xaml.cs +++ b/windows/App.xaml.cs @@ -32,6 +32,9 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => + new GpuTelemetryBackoffProvider(provider.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/windows/Models/GpuTelemetrySample.cs b/windows/Models/GpuTelemetrySample.cs new file mode 100644 index 00000000..b68c4d00 --- /dev/null +++ b/windows/Models/GpuTelemetrySample.cs @@ -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); + } +} diff --git a/windows/Models/SystemTelemetrySnapshot.cs b/windows/Models/SystemTelemetrySnapshot.cs index 31fe784c..8f38152a 100644 --- a/windows/Models/SystemTelemetrySnapshot.cs +++ b/windows/Models/SystemTelemetrySnapshot.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json.Serialization; namespace BurrowWin.Models; @@ -30,6 +31,40 @@ 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 numeric = new string(GpuStatus + .Where(character => char.IsDigit(character) || character is '.' or '-') + .ToArray()); + 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"); @@ -56,6 +91,9 @@ public static SystemTelemetrySnapshot Empty(DateTimeOffset capturedAt) 0, 0, "Unavailable", - []); + []) + { + GpuUnavailableReason = "GPU telemetry has not been sampled." + }; } } diff --git a/windows/Services/GpuTelemetryBackoffProvider.cs b/windows/Services/GpuTelemetryBackoffProvider.cs new file mode 100644 index 00000000..45ddae84 --- /dev/null +++ b/windows/Services/GpuTelemetryBackoffProvider.cs @@ -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 _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 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; + } +} diff --git a/windows/Services/IGpuTelemetryProvider.cs b/windows/Services/IGpuTelemetryProvider.cs new file mode 100644 index 00000000..5bc4cf9a --- /dev/null +++ b/windows/Services/IGpuTelemetryProvider.cs @@ -0,0 +1,8 @@ +using BurrowWin.Models; + +namespace BurrowWin.Services; + +public interface IGpuTelemetryProvider +{ + GpuTelemetrySample Capture(); +} diff --git a/windows/Services/LocalMcpServerService.cs b/windows/Services/LocalMcpServerService.cs index fd6c2811..2ff2387a 100644 --- a/windows/Services/LocalMcpServerService.cs +++ b/windows/Services/LocalMcpServerService.cs @@ -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, diff --git a/windows/Services/SystemTelemetryFormatter.cs b/windows/Services/SystemTelemetryFormatter.cs index 0ec8b8fb..389f9929 100644 --- a/windows/Services/SystemTelemetryFormatter.cs +++ b/windows/Services/SystemTelemetryFormatter.cs @@ -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"; + } } diff --git a/windows/Services/WindowsGpuPerformanceCounterProvider.cs b/windows/Services/WindowsGpuPerformanceCounterProvider.cs new file mode 100644 index 00000000..c8b5e381 --- /dev/null +++ b/windows/Services/WindowsGpuPerformanceCounterProvider.cs @@ -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."); + } + + using 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; + } +} diff --git a/windows/Services/WindowsSystemTelemetryService.cs b/windows/Services/WindowsSystemTelemetryService.cs index baa0ceb8..dd6317d1 100644 --- a/windows/Services/WindowsSystemTelemetryService.cs +++ b/windows/Services/WindowsSystemTelemetryService.cs @@ -9,6 +9,12 @@ namespace BurrowWin.Services; public sealed class WindowsSystemTelemetryService : ISystemTelemetryService { private static readonly TimeSpan SampleWindow = TimeSpan.FromMilliseconds(350); + private readonly IGpuTelemetryProvider _gpuTelemetryProvider; + + public WindowsSystemTelemetryService(IGpuTelemetryProvider gpuTelemetryProvider) + { + _gpuTelemetryProvider = gpuTelemetryProvider; + } public async Task CaptureAsync(CancellationToken cancellationToken = default) { @@ -26,6 +32,7 @@ public async Task CaptureAsync(CancellationToken cancel var memory = GetMemory(); var disk = GetSystemDisk(); var battery = GetBattery(); + var gpu = _gpuTelemetryProvider.Capture(); var cpuPercent = elapsed.TotalMilliseconds <= 0 ? 0 @@ -44,7 +51,7 @@ public async Task CaptureAsync(CancellationToken cancel disk.TotalBytes, Math.Max(0, (networkAfter.ReceivedBytes - networkBefore.ReceivedBytes) / networkSeconds), Math.Max(0, (networkAfter.SentBytes - networkBefore.SentBytes) / networkSeconds), - GetGpuStatus(), + gpu.Status, BuildTopProcesses(processesBefore, processesAfter, elapsed)) { NetworkInterfaceName = networkAfter.InterfaceName, @@ -53,7 +60,9 @@ public async Task CaptureAsync(CancellationToken cancel BatteryChargePercent = battery.ChargePercent, BatteryStatusText = battery.StatusText, BatteryHealthText = battery.HealthText, - BatteryEstimatedSecondsRemaining = battery.EstimatedSecondsRemaining + BatteryEstimatedSecondsRemaining = battery.EstimatedSecondsRemaining, + GpuUsagePercent = gpu.UsagePercent, + GpuUnavailableReason = gpu.UnavailableReason }; } @@ -314,50 +323,6 @@ private static IReadOnlyList GetTopProcesses() .ToArray(); } - private static string GetGpuStatus() - { - try - { - const string categoryName = "GPU Engine"; - const string counterName = "Utilization Percentage"; - - if (!PerformanceCounterCategory.Exists(categoryName)) - { - return "Unavailable"; - } - - var category = new PerformanceCounterCategory(categoryName); - var instanceNames = category.GetInstanceNames() - .Where(name => name.Contains("engtype_3D", StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - if (instanceNames.Length == 0) - { - return "Unavailable"; - } - - double total = 0; - foreach (var instanceName in instanceNames) - { - using var counter = new PerformanceCounter(categoryName, counterName, instanceName, readOnly: true); - try - { - total += counter.NextValue(); - } - catch - { - // GPU engine instances may disappear while being sampled. - } - } - - return $"3D {ClampPercent(total):0.0}%"; - } - catch (Exception ex) when (ex is InvalidOperationException or UnauthorizedAccessException or PlatformNotSupportedException) - { - return "Unavailable"; - } - } - private static double Percent(long used, long total) { return total <= 0 ? 0 : ClampPercent((double)used / total * 100); diff --git a/windows/Tests/BurrowWin.Tests/BurrowWin.Tests.csproj b/windows/Tests/BurrowWin.Tests/BurrowWin.Tests.csproj index 12cf3776..36abe0e9 100644 --- a/windows/Tests/BurrowWin.Tests/BurrowWin.Tests.csproj +++ b/windows/Tests/BurrowWin.Tests/BurrowWin.Tests.csproj @@ -29,6 +29,7 @@ + @@ -47,11 +48,13 @@ + + diff --git a/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs b/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs new file mode 100644 index 00000000..1fea90da --- /dev/null +++ b/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs @@ -0,0 +1,136 @@ +using System.ComponentModel; +using BurrowWin.Models; +using BurrowWin.Services; +using Xunit; + +namespace BurrowWin.Tests; + +public sealed class GpuTelemetryBackoffProviderTests +{ + [Fact] + public void Capture_DisabledCounterService_BacksOffAndKeepsZeroDistinctFromUnavailable() + { + var now = DateTimeOffset.Parse("2026-08-05T00:00:00Z"); + var source = new ScriptedGpuTelemetryProvider( + [ + () => throw new Win32Exception(1058, "The service cannot be started because it is disabled."), + () => GpuTelemetrySample.Available(0) + ]); + var provider = new GpuTelemetryBackoffProvider( + source, + () => now, + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(5)); + + var unavailable = provider.Capture(); + + Assert.False(unavailable.IsAvailable); + Assert.Null(unavailable.UsagePercent); + Assert.Equal(now.AddSeconds(30), unavailable.RetryAfter); + Assert.Contains(nameof(Win32Exception), unavailable.UnavailableReason); + Assert.Equal(1, source.CaptureCount); + + now = now.AddSeconds(29); + var cached = provider.Capture(); + + Assert.False(cached.IsAvailable); + Assert.Equal(1, source.CaptureCount); + + now = now.AddSeconds(1); + var zero = provider.Capture(); + + Assert.True(zero.IsAvailable); + Assert.Equal(0, zero.UsagePercent); + Assert.Equal("3D 0.0%", zero.Status); + Assert.Null(zero.RetryAfter); + Assert.Equal(2, source.CaptureCount); + } + + [Fact] + public void Capture_RepeatedUnavailableSamples_UsesBoundedExponentialBackoff() + { + var now = DateTimeOffset.Parse("2026-08-05T00:00:00Z"); + var source = new ConstantGpuTelemetryProvider( + GpuTelemetrySample.Unavailable("Counters unavailable.")); + var provider = new GpuTelemetryBackoffProvider( + source, + () => now, + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(40)); + + Assert.Equal(now.AddSeconds(10), provider.Capture().RetryAfter); + now = now.AddSeconds(10); + Assert.Equal(now.AddSeconds(20), provider.Capture().RetryAfter); + now = now.AddSeconds(20); + Assert.Equal(now.AddSeconds(40), provider.Capture().RetryAfter); + now = now.AddSeconds(40); + Assert.Equal(now.AddSeconds(40), provider.Capture().RetryAfter); + + Assert.Equal(4, source.CaptureCount); + } + + [Fact] + public void Snapshot_EffectiveGpuUsage_PreservesLegacyZeroAndUnavailableValues() + { + var zero = CreateSnapshot("3D 0.0%"); + var unavailable = CreateSnapshot("Unavailable"); + + Assert.True(zero.IsGpuAvailable); + Assert.Equal(0, zero.EffectiveGpuUsagePercent); + Assert.False(unavailable.IsGpuAvailable); + Assert.Null(unavailable.EffectiveGpuUsagePercent); + } + + private static SystemTelemetrySnapshot CreateSnapshot(string gpuStatus) + { + return new SystemTelemetrySnapshot( + DateTimeOffset.Parse("2026-08-05T00:00:00Z"), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + gpuStatus, + []); + } + + private sealed class ScriptedGpuTelemetryProvider : IGpuTelemetryProvider + { + private readonly Queue> _captures; + + public ScriptedGpuTelemetryProvider(IEnumerable> captures) + { + _captures = new Queue>(captures); + } + + public int CaptureCount { get; private set; } + + public GpuTelemetrySample Capture() + { + CaptureCount++; + return _captures.Dequeue()(); + } + } + + private sealed class ConstantGpuTelemetryProvider : IGpuTelemetryProvider + { + private readonly GpuTelemetrySample _sample; + + public ConstantGpuTelemetryProvider(GpuTelemetrySample sample) + { + _sample = sample; + } + + public int CaptureCount { get; private set; } + + public GpuTelemetrySample Capture() + { + CaptureCount++; + return _sample; + } + } +} diff --git a/windows/Tests/BurrowWin.Tests/SystemTelemetryFormatterTests.cs b/windows/Tests/BurrowWin.Tests/SystemTelemetryFormatterTests.cs index d7a30453..5a3e40b4 100644 --- a/windows/Tests/BurrowWin.Tests/SystemTelemetryFormatterTests.cs +++ b/windows/Tests/BurrowWin.Tests/SystemTelemetryFormatterTests.cs @@ -63,4 +63,31 @@ public void SnapshotSummaries_UseFormattedBytes() Assert.Equal("0%", snapshot.TopProcesses[0].CpuUsageText); Assert.Equal("1 KB/s", SystemTelemetryFormatter.Rate(snapshot.NetworkReceivedBytesPerSecond)); } + + [Fact] + public void GpuMetric_DistinguishesUnavailableFromRealZero() + { + var unavailable = CreateGpuSnapshot("Unavailable"); + var zero = CreateGpuSnapshot("Unavailable") with { GpuUsagePercent = 0 }; + + Assert.Equal("Unavailable", SystemTelemetryFormatter.GpuMetric(unavailable)); + Assert.Equal("3D 0.0%", SystemTelemetryFormatter.GpuMetric(zero)); + } + + private static SystemTelemetrySnapshot CreateGpuSnapshot(string gpuStatus) + { + return new SystemTelemetrySnapshot( + DateTimeOffset.UnixEpoch, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + gpuStatus, + []); + } } diff --git a/windows/ViewModels/DashboardViewModel.cs b/windows/ViewModels/DashboardViewModel.cs index cab272c4..3345359e 100644 --- a/windows/ViewModels/DashboardViewModel.cs +++ b/windows/ViewModels/DashboardViewModel.cs @@ -314,10 +314,11 @@ private async Task RefreshTelemetryAsync() NetworkAdapterText = BuildNetworkEndpointText(snapshot); NetworkFooter = $"↓ {SystemTelemetryFormatter.Rate(snapshot.NetworkReceivedBytesPerSecond)} ↑ {SystemTelemetryFormatter.Rate(snapshot.NetworkSentBytesPerSecond)} · {NetworkAdapterText}"; - GpuStatus = snapshot.GpuStatus; - GpuMetricText = string.Equals(snapshot.GpuStatus, "Unavailable", StringComparison.OrdinalIgnoreCase) - ? "-" - : snapshot.GpuStatus; + GpuStatus = SystemTelemetryFormatter.GpuMetric(snapshot); + GpuMetricText = GpuStatus; + GpuFooter = snapshot.IsGpuAvailable + ? "Windows GPU engine" + : snapshot.GpuUnavailableReason ?? "Windows GPU telemetry unavailable"; CapturedAt = snapshot.CapturedAt.ToString("HH:mm:ss", CultureInfo.InvariantCulture); CpuFooter = string.Create( CultureInfo.InvariantCulture, @@ -345,7 +346,7 @@ private async Task RefreshTelemetryAsync() MemoryStatusChart = BuildChart(chartSamples, sample => sample.MemoryUsagePercent, 100, SystemTelemetryFormatter.Percent); (NetworkDownloadChart, NetworkUploadChart) = BuildNetworkCharts(chartSamples); NetworkStatusChart = NetworkDownloadChart; - GpuStatusChart = BuildChart(chartSamples, sample => ParseGpuPercent(sample.GpuStatus), 100, SystemTelemetryFormatter.Percent); + GpuStatusChart = BuildGpuChart(chartSamples); FanStatusChart = HistoryChartSeries.Empty("0 RPM", "avg 0 RPM"); TelemetryHistorySummary = BuildTelemetryHistorySummary(recentSnapshots); @@ -560,10 +561,28 @@ private static IReadOnlyList BuildPaddedValues( return values; } - private static double ParseGpuPercent(string gpuStatus) + private static HistoryChartSeries BuildGpuChart(IReadOnlyList samples) { - var numeric = new string(gpuStatus.Where(character => char.IsDigit(character) || character == '.').ToArray()); - return double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : 0; + var ordered = samples + .OrderBy(sample => sample.CapturedAt) + .TakeLast(12) + .ToArray(); + var values = ordered + .Select(sample => sample.EffectiveGpuUsagePercent) + .Where(value => value.HasValue) + .Select(value => value!.Value) + .ToArray(); + + if (values.Length == 0) + { + return HistoryChartSeries.Empty("Unavailable", "avg unavailable"); + } + + var chart = BuildChartFromValues(values, 100, SystemTelemetryFormatter.Percent); + var latestText = ordered[^1].EffectiveGpuUsagePercent is { } latest + ? SystemTelemetryFormatter.Percent(latest) + : "Unavailable"; + return new HistoryChartSeries(latestText, chart.AverageText, chart.Points); } private static string BuildUptimeText(DateTimeOffset capturedAt) From 399ea3c66bd7485459bce2733fe059626f9be309 Mon Sep 17 00:00:00 2001 From: Ltcc0 <158467799+Ltcc0@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:03:26 +0800 Subject: [PATCH 2/3] Fix Windows GPU category lifetime (BUR-11) --- windows/Services/WindowsGpuPerformanceCounterProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/Services/WindowsGpuPerformanceCounterProvider.cs b/windows/Services/WindowsGpuPerformanceCounterProvider.cs index c8b5e381..5775c999 100644 --- a/windows/Services/WindowsGpuPerformanceCounterProvider.cs +++ b/windows/Services/WindowsGpuPerformanceCounterProvider.cs @@ -19,7 +19,7 @@ public GpuTelemetrySample Capture() return GpuTelemetrySample.Unavailable("The GPU Engine performance-counter category is unavailable."); } - using var category = new PerformanceCounterCategory(CategoryName); + var category = new PerformanceCounterCategory(CategoryName); var instanceNames = category.GetInstanceNames() .Where(name => name.Contains("engtype_3D", StringComparison.OrdinalIgnoreCase)) .ToArray(); From 7735ccf29cd65c872025a0ccc35bbbebc6dcdb57 Mon Sep 17 00:00:00 2001 From: Ltcc0 <158467799+Ltcc0@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:13:41 +0800 Subject: [PATCH 3/3] Fix legacy GPU percentage parsing (BUR-11) --- windows/Models/SystemTelemetrySnapshot.cs | 24 ++++++++++++++++--- .../GpuTelemetryBackoffProviderTests.cs | 3 +++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/windows/Models/SystemTelemetrySnapshot.cs b/windows/Models/SystemTelemetrySnapshot.cs index 8f38152a..0295def8 100644 --- a/windows/Models/SystemTelemetrySnapshot.cs +++ b/windows/Models/SystemTelemetrySnapshot.cs @@ -53,9 +53,27 @@ public double? EffectiveGpuUsagePercent return null; } - var numeric = new string(GpuStatus - .Where(character => char.IsDigit(character) || character is '.' or '-') - .ToArray()); + 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; diff --git a/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs b/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs index 1fea90da..b8448815 100644 --- a/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs +++ b/windows/Tests/BurrowWin.Tests/GpuTelemetryBackoffProviderTests.cs @@ -73,10 +73,13 @@ public void Capture_RepeatedUnavailableSamples_UsesBoundedExponentialBackoff() public void Snapshot_EffectiveGpuUsage_PreservesLegacyZeroAndUnavailableValues() { var zero = CreateSnapshot("3D 0.0%"); + var nonZero = CreateSnapshot("3D 37.5%"); var unavailable = CreateSnapshot("Unavailable"); Assert.True(zero.IsGpuAvailable); Assert.Equal(0, zero.EffectiveGpuUsagePercent); + Assert.True(nonZero.IsGpuAvailable); + Assert.Equal(37.5, nonZero.EffectiveGpuUsagePercent); Assert.False(unavailable.IsGpuAvailable); Assert.Null(unavailable.EffectiveGpuUsagePercent); }