diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/ExtensionLoadState.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/ExtensionLoadState.cs new file mode 100644 index 0000000000..2a6f39f84b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/ExtensionLoadState.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Common; + +/// +/// Shared constants for the extension load sentinel file used by +/// ProviderLoadGuard and provider-specific crash sentinels to +/// coordinate crash detection across process lifetimes. +/// +public static class ExtensionLoadState +{ + /// + /// Name of the sentinel JSON file written to the config directory. + /// Both the app-level guard and individual extension sentinels must + /// read and write the same file for crash detection to work. + /// + public const string SentinelFileName = "extensionLoadState.json"; + + /// + /// JSON property name storing the owning provider id for a guarded block. + /// + public const string ProviderIdKey = "providerId"; + + /// + /// JSON property name indicating a guarded block was active when the + /// process exited. + /// + public const string LoadingKey = "loading"; + + /// + /// JSON property name storing the consecutive crash count for a guarded + /// block. + /// + public const string CrashCountKey = "crashCount"; + + /// + /// Shared lock that must be held around every read-modify-write cycle + /// on the sentinel file. Both ProviderLoadGuard and + /// provider-specific crash sentinels run in the same process and would + /// otherwise race on the file, silently dropping entries. + /// + public static readonly object SentinelFileLock = new(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderCrashSentinel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderCrashSentinel.cs new file mode 100644 index 0000000000..6903306bef --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderCrashSentinel.cs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Common.Helpers; + +/// +/// Tracks guarded provider blocks in the shared extension-load sentinel file so +/// callers can fail closed after repeated native crashes that bypass managed +/// exception handling. +/// +public sealed class ProviderCrashSentinel +{ + private readonly string _providerId; + private readonly Lock _sentinelLock = new(); + private readonly HashSet _completedBlocks = []; + private readonly HashSet _activeBlocks = []; + + public ProviderCrashSentinel(string providerId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerId); + _providerId = providerId; + } + + public bool BeginBlock(string blockSuffix) + { + var blockId = CreateBlockId(blockSuffix); + + lock (_sentinelLock) + { + if (_completedBlocks.Contains(blockId) || !_activeBlocks.Add(blockId)) + { + return false; + } + + UpdateState( + state => + { + var entry = GetOrCreateEntry(state, blockId); + entry[ExtensionLoadState.LoadingKey] = true; + }); + return true; + } + } + + public void CompleteBlock(string blockSuffix) + { + var blockId = CreateBlockId(blockSuffix); + + lock (_sentinelLock) + { + if (!_activeBlocks.Remove(blockId)) + { + return; + } + + _completedBlocks.Add(blockId); + UpdateState(state => state.Remove(blockId)); + } + } + + public void CancelBlock(string blockSuffix) + { + var blockId = CreateBlockId(blockSuffix); + + lock (_sentinelLock) + { + if (!_activeBlocks.Remove(blockId)) + { + return; + } + + UpdateState(state => state.Remove(blockId)); + } + } + + public void ClearProviderState() + { + lock (_sentinelLock) + { + _completedBlocks.RemoveWhere(blockId => blockId.StartsWith(_providerId + ".", StringComparison.Ordinal)); + _activeBlocks.RemoveWhere(blockId => blockId.StartsWith(_providerId + ".", StringComparison.Ordinal)); + + UpdateState( + state => + { + var keysToRemove = state + .Where(kvp => IsProviderEntry(kvp.Key, kvp.Value as JsonObject)) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in keysToRemove) + { + state.Remove(key); + } + }); + } + } + + private void UpdateState(Action update) + { + try + { + lock (ExtensionLoadState.SentinelFileLock) + { + var sentinelPath = GetSentinelPath(); + var state = LoadState(sentinelPath); + update(state); + SaveState(sentinelPath, state); + } + } + catch (Exception ex) + { + CoreLogger.LogError($"Failed to update crash sentinel state for provider '{_providerId}'.", ex); + } + } + + private static JsonObject LoadState(string sentinelPath) + { + if (!File.Exists(sentinelPath)) + { + return []; + } + + try + { + var json = File.ReadAllText(sentinelPath); + if (string.IsNullOrWhiteSpace(json)) + { + CoreLogger.LogWarning($"Crash sentinel state file '{sentinelPath}' was empty. Treating as empty state."); + DeleteInvalidStateFile(sentinelPath); + return []; + } + + if (JsonNode.Parse(json) is JsonObject state) + { + return state; + } + + CoreLogger.LogError($"Crash sentinel state file '{sentinelPath}' did not contain a JSON object. Treating as empty state."); + DeleteInvalidStateFile(sentinelPath); + } + catch (JsonException ex) + { + CoreLogger.LogError($"Failed to parse crash sentinel state from '{sentinelPath}'. Treating as empty state.", ex); + DeleteInvalidStateFile(sentinelPath); + } + catch (IOException ex) + { + CoreLogger.LogError($"Failed to read crash sentinel state from '{sentinelPath}'. Treating as empty state.", ex); + } + catch (UnauthorizedAccessException ex) + { + CoreLogger.LogError($"Access denied reading crash sentinel state from '{sentinelPath}'. Treating as empty state.", ex); + } + + return []; + } + + private static void DeleteInvalidStateFile(string sentinelPath) + { + try + { + File.Delete(sentinelPath); + } + catch (IOException ex) + { + CoreLogger.LogError($"Failed to delete invalid crash sentinel state file '{sentinelPath}'.", ex); + } + catch (UnauthorizedAccessException ex) + { + CoreLogger.LogError($"Access denied deleting invalid crash sentinel state file '{sentinelPath}'.", ex); + } + } + + private static void SaveState(string sentinelPath, JsonObject state) + { + if (state.Count == 0) + { + if (File.Exists(sentinelPath)) + { + File.Delete(sentinelPath); + } + + return; + } + + var directory = Path.GetDirectoryName(sentinelPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = sentinelPath + ".tmp"; + File.WriteAllText(tempPath, state.ToJsonString()); + File.Move(tempPath, sentinelPath, overwrite: true); + } + + private JsonObject GetOrCreateEntry(JsonObject state, string blockId) + { + if (state[blockId] is JsonObject existing) + { + existing[ExtensionLoadState.ProviderIdKey] = _providerId; + return existing; + } + + var entry = new JsonObject + { + [ExtensionLoadState.ProviderIdKey] = _providerId, + [ExtensionLoadState.LoadingKey] = false, + [ExtensionLoadState.CrashCountKey] = 0, + }; + state[blockId] = entry; + return entry; + } + + private bool IsProviderEntry(string blockId, JsonObject? entry) + { + var providerId = entry?[ExtensionLoadState.ProviderIdKey]?.GetValue(); + if (string.Equals(providerId, _providerId, StringComparison.Ordinal)) + { + return true; + } + + return blockId.StartsWith(_providerId + ".", StringComparison.Ordinal); + } + + private string CreateBlockId(string blockSuffix) + { + return $"{_providerId}.{blockSuffix}"; + } + + private static string GetSentinelPath() + { + return Path.Combine(Utilities.BaseSettingsPath("Microsoft.CmdPal"), ExtensionLoadState.SentinelFileName); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderLoadGuard.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderLoadGuard.cs new file mode 100644 index 0000000000..2da90693df --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ProviderLoadGuard.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; + +namespace Microsoft.CmdPal.Common.Helpers; + +/// +/// Reads the shared provider crash sentinel file at startup, increments crash +/// counts for blocks left marked as active, and determines which providers +/// should be soft-disabled for the current session. +/// +public sealed class ProviderLoadGuard +{ + private const int MaxConsecutiveCrashes = 2; + + private readonly string _sentinelPath; + private readonly HashSet _disabledProviders = []; + + public ProviderLoadGuard(string configDirectory) + { + _sentinelPath = Path.Combine(configDirectory, ExtensionLoadState.SentinelFileName); + DetectCrashes(); + } + + /// + /// Returns true if the provider has been disabled due to repeated crashes + /// in one of its tracked guarded blocks. + /// + public bool IsProviderDisabled(string providerId) => _disabledProviders.Contains(providerId); + + /// + /// Call immediately before attempting a guarded operation. + /// Marks the block as "loading" in the sentinel file so that a + /// subsequent native crash leaves evidence on disk. + /// + public void Enter(string blockId, string providerId) + { + UpdateState(state => + { + var entry = GetOrCreateEntry(state, blockId, providerId); + entry[ExtensionLoadState.LoadingKey] = true; + }); + } + + /// + /// Call after a guarded operation succeeds or fails gracefully via managed + /// exception. Clears the loading flag and removes the block entry. + /// + public void Exit(string blockId) + { + UpdateState(state => state.Remove(blockId)); + } + + /// + /// Removes any persisted crash state for a provider so it can be retried + /// on the next launch. + /// + public void ClearProvider(string providerId) + { + _disabledProviders.Remove(providerId); + UpdateState(state => + { + var keysToRemove = state + .Where(kvp => TryGetProviderId(kvp.Key, kvp.Value as JsonObject) == providerId) + .Select(kvp => kvp.Key) + .ToArray(); + + foreach (var key in keysToRemove) + { + state.Remove(key); + } + }); + } + + private void DetectCrashes() + { + // Read the sentinel file once at startup to detect providers that + // crashed on the previous launch, then write back the updated state. + lock (ExtensionLoadState.SentinelFileLock) + { + var state = ReadState(); + + var keysToCheck = state.Select(kvp => kvp.Key).ToArray(); + + foreach (var key in keysToCheck) + { + if (state[key] is not JsonObject entry) + { + continue; + } + + var providerId = TryGetProviderId(key, entry); + var wasLoading = entry[ExtensionLoadState.LoadingKey]?.GetValue() ?? false; + + if (wasLoading) + { + // The guarded block was active when the process died. + var crashCount = (entry[ExtensionLoadState.CrashCountKey]?.GetValue() ?? 0) + 1; + entry[ExtensionLoadState.CrashCountKey] = crashCount; + entry[ExtensionLoadState.LoadingKey] = false; + + if (crashCount >= MaxConsecutiveCrashes) + { + _disabledProviders.Add(providerId); + CoreLogger.LogError($"Provider '{providerId}' disabled after {crashCount} consecutive crash(es) in guarded block '{key}'."); + } + else + { + CoreLogger.LogWarning($"Guarded block '{key}' for provider '{providerId}' crashed on previous launch (crash {crashCount}/{MaxConsecutiveCrashes}). Will retry."); + } + } + + var currentCrashCount = entry[ExtensionLoadState.CrashCountKey]?.GetValue() ?? 0; + if (currentCrashCount >= MaxConsecutiveCrashes) + { + // Persist disabled state from a previous session. + _disabledProviders.Add(providerId); + } + + if (!(entry[ExtensionLoadState.LoadingKey]?.GetValue() ?? false) && currentCrashCount == 0) + { + state.Remove(key); + } + } + + WriteState(state); + } + } + + /// + /// Reads the sentinel file, applies a mutation, and writes it back + /// under to prevent + /// concurrent writers from clobbering each other's entries. + /// + private void UpdateState(Action mutate) + { + try + { + lock (ExtensionLoadState.SentinelFileLock) + { + var state = ReadState(); + mutate(state); + WriteState(state); + } + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to update extension load sentinel file.", ex); + } + } + + private static JsonObject GetOrCreateEntry(JsonObject state, string blockId, string providerId) + { + if (state[blockId] is JsonObject existing) + { + existing[ExtensionLoadState.ProviderIdKey] = providerId; + return existing; + } + + var entry = new JsonObject + { + [ExtensionLoadState.ProviderIdKey] = providerId, + [ExtensionLoadState.LoadingKey] = false, + [ExtensionLoadState.CrashCountKey] = 0, + }; + state[blockId] = entry; + return entry; + } + + private static string TryGetProviderId(string blockId, JsonObject? entry) + { + var providerId = entry?[ExtensionLoadState.ProviderIdKey]?.GetValue(); + if (!string.IsNullOrWhiteSpace(providerId)) + { + return providerId; + } + + var separatorIndex = blockId.IndexOf('.'); + return separatorIndex > 0 ? blockId[..separatorIndex] : blockId; + } + + private JsonObject ReadState() + { + try + { + if (File.Exists(_sentinelPath)) + { + var json = File.ReadAllText(_sentinelPath); + return JsonNode.Parse(json)?.AsObject() ?? []; + } + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to read extension load sentinel file.", ex); + } + + return []; + } + + private void WriteState(JsonObject state) + { + try + { + if (state.Count == 0) + { + if (File.Exists(_sentinelPath)) + { + File.Delete(_sentinelPath); + } + + return; + } + + var directory = Path.GetDirectoryName(_sentinelPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + var tempPath = _sentinelPath + ".tmp"; + File.WriteAllText(tempPath, state.ToJsonString()); + File.Move(tempPath, _sentinelPath, overwrite: true); + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to write extension load sentinel file.", ex); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 81d5892664..7ad32a2213 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -125,7 +125,7 @@ public partial class App : Application, IDisposable services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - AddBuiltInCommands(services); + AddBuiltInCommands(services, appInfoService.ConfigDirectory); AddCoreServices(services, appInfoService); @@ -134,8 +134,10 @@ public partial class App : Application, IDisposable return services.BuildServiceProvider(); } - private static void AddBuiltInCommands(ServiceCollection services) + private static void AddBuiltInCommands(ServiceCollection services, string configDirectory) { + var providerLoadGuard = new ProviderLoadGuard(configDirectory); + // Built-in Commands. Order matters - this is the order they'll be presented by default. var allApps = new AllAppsCommandProvider(); var files = new IndexerCommandsProvider(); @@ -166,8 +168,7 @@ public partial class App : Application, IDisposable } catch (Exception ex) { - Logger.LogError("Couldn't load winget"); - Logger.LogError(ex.ToString()); + Logger.LogError("Couldn't load winget", ex); } services.AddSingleton(); @@ -178,7 +179,37 @@ public partial class App : Application, IDisposable services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + + var performanceMonitorSoftDisabled = providerLoadGuard.IsProviderDisabled(PerformanceMonitorCommandsProvider.ProviderIdValue); + if (performanceMonitorSoftDisabled) + { + Logger.LogWarning("Performance monitor is temporarily disabled after repeated crashes. Loading placeholder pages instead of activating performance counters."); + } + + if (!performanceMonitorSoftDisabled) + { + providerLoadGuard.Enter(PerformanceMonitorCommandsProvider.ProviderLoadGuardBlockId, PerformanceMonitorCommandsProvider.ProviderIdValue); + } + + try + { + var performanceMonitor = new PerformanceMonitorCommandsProvider(performanceMonitorSoftDisabled); + services.AddSingleton(performanceMonitor); + + if (!performanceMonitorSoftDisabled) + { + providerLoadGuard.Exit(PerformanceMonitorCommandsProvider.ProviderLoadGuardBlockId); + } + } + catch (Exception ex) + { + if (!performanceMonitorSoftDisabled) + { + providerLoadGuard.Exit(PerformanceMonitorCommandsProvider.ProviderLoadGuardBlockId); + } + + Logger.LogError("Couldn't load performance monitor", ex); + } } private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Common.UnitTests/Helpers/ProviderLoadGuardTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Common.UnitTests/Helpers/ProviderLoadGuardTests.cs new file mode 100644 index 0000000000..2c12f3db3d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Common.UnitTests/Helpers/ProviderLoadGuardTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Common.Helpers; + +namespace Microsoft.CmdPal.Common.UnitTests.Helpers; + +[TestClass] +public class ProviderLoadGuardTests +{ + private readonly List _temporaryDirectories = []; + + [TestMethod] + public void EnterAndExit_PersistAndClearGuardedBlock() + { + var configDirectory = CreateTempDirectory(); + var sentinelPath = GetSentinelPath(configDirectory); + var guard = new ProviderLoadGuard(configDirectory); + + guard.Enter("Provider.Block", "Provider"); + + var state = ReadState(sentinelPath); + var entry = state["Provider.Block"] as JsonObject; + + Assert.IsNotNull(entry); + Assert.AreEqual("Provider", entry[ExtensionLoadState.ProviderIdKey]?.GetValue()); + Assert.AreEqual(true, entry[ExtensionLoadState.LoadingKey]?.GetValue()); + Assert.AreEqual(0, entry[ExtensionLoadState.CrashCountKey]?.GetValue()); + + guard.Exit("Provider.Block"); + + Assert.IsFalse(File.Exists(sentinelPath)); + } + + [TestMethod] + public void Constructor_DisablesProviderAfterSecondConsecutiveCrash() + { + var configDirectory = CreateTempDirectory(); + var sentinelPath = GetSentinelPath(configDirectory); + + WriteState( + sentinelPath, + new JsonObject + { + ["Provider.Block"] = CreateEntry("Provider", loading: true, crashCount: 1), + }); + + var guard = new ProviderLoadGuard(configDirectory); + + Assert.IsTrue(guard.IsProviderDisabled("Provider")); + + var state = ReadState(sentinelPath); + var entry = state["Provider.Block"] as JsonObject; + + Assert.IsNotNull(entry); + Assert.AreEqual(false, entry[ExtensionLoadState.LoadingKey]?.GetValue()); + Assert.AreEqual(2, entry[ExtensionLoadState.CrashCountKey]?.GetValue()); + } + + [TestMethod] + public void ClearProvider_RemovesDisabledStateAndOnlyMatchingEntries() + { + var configDirectory = CreateTempDirectory(); + var sentinelPath = GetSentinelPath(configDirectory); + + WriteState( + sentinelPath, + new JsonObject + { + ["CustomBlock"] = CreateEntry("Provider", loading: false, crashCount: 2), + ["OtherProvider.Block"] = CreateEntry("OtherProvider", loading: false, crashCount: 2), + }); + + var guard = new ProviderLoadGuard(configDirectory); + + Assert.IsTrue(guard.IsProviderDisabled("Provider")); + Assert.IsTrue(guard.IsProviderDisabled("OtherProvider")); + + guard.ClearProvider("Provider"); + + Assert.IsFalse(guard.IsProviderDisabled("Provider")); + Assert.IsTrue(guard.IsProviderDisabled("OtherProvider")); + + var state = ReadState(sentinelPath); + Assert.IsFalse(state.ContainsKey("CustomBlock")); + Assert.IsTrue(state.ContainsKey("OtherProvider.Block")); + } + + [DataTestMethod] + [DataRow("")] + [DataRow("{")] + [DataRow("[]")] + public void Constructor_RecoversFromInvalidSentinelContents(string invalidSentinelContents) + { + var configDirectory = CreateTempDirectory(); + var sentinelPath = GetSentinelPath(configDirectory); + File.WriteAllText(sentinelPath, invalidSentinelContents); + + var guard = new ProviderLoadGuard(configDirectory); + + Assert.IsFalse(guard.IsProviderDisabled("Provider")); + Assert.IsFalse(File.Exists(sentinelPath)); + + guard.Enter("Provider.Block", "Provider"); + + var state = ReadState(sentinelPath); + Assert.IsTrue(state.ContainsKey("Provider.Block")); + } + + [TestCleanup] + public void Cleanup() + { + foreach (var directory in _temporaryDirectories) + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + } + + private string CreateTempDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "CmdPal.ProviderLoadGuardTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + _temporaryDirectories.Add(directory); + return directory; + } + + private static JsonObject CreateEntry(string providerId, bool loading, int crashCount) + { + return new JsonObject + { + [ExtensionLoadState.ProviderIdKey] = providerId, + [ExtensionLoadState.LoadingKey] = loading, + [ExtensionLoadState.CrashCountKey] = crashCount, + }; + } + + private static string GetSentinelPath(string configDirectory) + { + return Path.Combine(configDirectory, ExtensionLoadState.SentinelFileName); + } + + private static JsonObject ReadState(string sentinelPath) + { + if (JsonNode.Parse(File.ReadAllText(sentinelPath)) is JsonObject state) + { + return state; + } + + throw new AssertFailedException($"Sentinel state at '{sentinelPath}' was not a JSON object."); + } + + private static void WriteState(string sentinelPath, JsonObject state) + { + Directory.CreateDirectory(Path.GetDirectoryName(sentinelPath)!); + File.WriteAllText(sentinelPath, state.ToJsonString()); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs index 99a02376ea..ab9caec88a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs @@ -9,13 +9,17 @@ using System.Linq; namespace CoreWidgetProvider.Helpers; -internal sealed partial class CPUStats : IDisposable +internal sealed partial class CPUStats : PerformanceCounterSourceBase, IDisposable { // CPU counters - private readonly PerformanceCounter _procPerf = new("Processor Information", "% Processor Utility", "_Total"); - private readonly PerformanceCounter _procPerformance = new("Processor Information", "% Processor Performance", "_Total"); - private readonly PerformanceCounter _procFrequency = new("Processor Information", "Processor Frequency", "_Total"); + private readonly PerformanceCounter? _procPerf; + private readonly PerformanceCounter? _procPerformance; + private readonly PerformanceCounter? _procFrequency; private readonly Dictionary _cpuCounters = new(); + private bool _processCountersInitialized; + private bool _cpuCounterReadFailureLogged; + private bool _processCounterEnumerationFailureLogged; + private bool _processCounterReadFailureLogged; internal sealed class ProcessStats { @@ -42,69 +46,121 @@ internal sealed partial class CPUStats : IDisposable new ProcessStats() ]; - InitCPUPerfCounters(); + _procPerf = CreatePerformanceCounter("Processor Information", "% Processor Utility", "_Total"); + _procPerformance = CreatePerformanceCounter("Processor Information", "% Processor Performance", "_Total"); + _procFrequency = CreatePerformanceCounter("Processor Information", "Processor Frequency", "_Total"); } - private void InitCPUPerfCounters() + private void EnsureCPUProcessCountersInitialized() { - var allProcesses = Process.GetProcesses().Where(p => (long)p.MainWindowHandle != 0); - - foreach (var process in allProcesses) + if (_processCountersInitialized) { - _cpuCounters.Add(process, new PerformanceCounter("Process", "% Processor Time", process.ProcessName, true)); + return; + } + + _processCountersInitialized = true; + + try + { + var allProcesses = Process.GetProcesses().Where(p => (long)p.MainWindowHandle != 0); + + foreach (var process in allProcesses) + { + try + { + var counter = CreatePerformanceCounter("Process", "% Processor Time", process.ProcessName, logFailure: false); + if (counter is not null) + { + _cpuCounters.Add(process, counter); + } + } + catch (Exception) + { + // Skip processes whose counters cannot be created. + } + } + } + catch (Exception ex) + { + LogFailureOnce(ref _processCounterEnumerationFailureLogged, "Failed to initialize CPU process performance counters.", ex); } } public void GetData(bool includeTopProcesses) { - var timer = Stopwatch.StartNew(); - CpuUsage = _procPerf.NextValue() / 100; - var usageMs = timer.ElapsedMilliseconds; - CpuSpeed = _procFrequency.NextValue() * (_procPerformance.NextValue() / 100); - var speedMs = timer.ElapsedMilliseconds - usageMs; - lock (CpuChartValues) + try { - ChartHelper.AddNextChartValue(CpuUsage * 100, CpuChartValues); - } - - var chartMs = timer.ElapsedMilliseconds - speedMs; - - var processCPUUsages = new Dictionary(); - - if (includeTopProcesses) - { - foreach (var processCounter in _cpuCounters) + var timer = Stopwatch.StartNew(); + if (_procPerf is not null) { - try + CpuUsage = _procPerf.NextValue() / 100; + } + + var usageMs = timer.ElapsedMilliseconds; + if (_procFrequency is not null && _procPerformance is not null) + { + CpuSpeed = _procFrequency.NextValue() * (_procPerformance.NextValue() / 100); + } + + var speedMs = timer.ElapsedMilliseconds - usageMs; + lock (CpuChartValues) + { + ChartHelper.AddNextChartValue(CpuUsage * 100, CpuChartValues); + } + + var chartMs = timer.ElapsedMilliseconds - speedMs; + + var processCPUUsages = new Dictionary(); + + if (includeTopProcesses) + { + EnsureCPUProcessCountersInitialized(); + + var countersToRemove = new List(); + foreach (var processCounter in _cpuCounters.ToArray()) { - // process might be terminated - processCPUUsages.Add(processCounter.Key, processCounter.Value.NextValue() / Environment.ProcessorCount); + try + { + // process might be terminated + processCPUUsages.Add(processCounter.Key, processCounter.Value.NextValue() / Environment.ProcessorCount); + } + catch (InvalidOperationException) + { + countersToRemove.Add(processCounter.Key); + } + catch (Exception ex) + { + LogFailureOnce(ref _processCounterReadFailureLogged, "Failed while reading CPU process performance counters.", ex); + } } - catch (InvalidOperationException) + + foreach (var process in countersToRemove) { - // _log.Information($"ProcessCounter Key {processCounter.Key} no longer exists, removing from _cpuCounters."); - _cpuCounters.Remove(processCounter.Key); + if (_cpuCounters.Remove(process, out var counter)) + { + counter.Dispose(); + } } - catch (Exception) + + var cpuIndex = 0; + foreach (var processCPUValue in processCPUUsages.OrderByDescending(x => x.Value).Take(3)) { - // _log.Error(ex, "Error going through process counters."); + ProcessCPUStats[cpuIndex].Process = processCPUValue.Key; + ProcessCPUStats[cpuIndex].CpuUsage = processCPUValue.Value; + cpuIndex++; } } - var cpuIndex = 0; - foreach (var processCPUValue in processCPUUsages.OrderByDescending(x => x.Value).Take(3)) - { - ProcessCPUStats[cpuIndex].Process = processCPUValue.Key; - ProcessCPUStats[cpuIndex].CpuUsage = processCPUValue.Value; - cpuIndex++; - } + timer.Stop(); + var total = timer.ElapsedMilliseconds; + var processesMs = total - chartMs; + + // CoreLogger.LogDebug($"[{usageMs}]+[{speedMs}]+[{chartMs}]+[{processesMs}]=[{total}]"); + } + catch (Exception ex) + { + LogFailureOnce(ref _cpuCounterReadFailureLogged, "Failed while reading CPU performance counters.", ex); } - - timer.Stop(); - var total = timer.ElapsedMilliseconds; - var processesMs = total - chartMs; - - // CoreLogger.LogDebug($"[{usageMs}]+[{speedMs}]+[{chartMs}]+[{processesMs}]=[{total}]"); } internal string CreateCPUImageUrl() @@ -134,9 +190,9 @@ internal sealed partial class CPUStats : IDisposable public void Dispose() { - _procPerf.Dispose(); - _procPerformance.Dispose(); - _procFrequency.Dispose(); + _procPerf?.Dispose(); + _procPerformance?.Dispose(); + _procFrequency?.Dispose(); foreach (var counter in _cpuCounters.Values) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs index 940411a6b7..35bace54f9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs @@ -3,22 +3,23 @@ // See the LICENSE file in the project root for more information. using System; +using Microsoft.CmdPal.Ext.PerformanceMonitor; using Timer = System.Timers.Timer; namespace CoreWidgetProvider.Helpers; internal sealed partial class DataManager : IDisposable { - private readonly SystemData _systemData; + private readonly SystemData _systemData = SystemData.Shared; private readonly DataType _dataType; private readonly Timer _updateTimer; private readonly Action _updateAction; + private bool _updateFailureLogged; private const int OneSecondInMilliseconds = 1000; public DataManager(DataType type, Action updateWidget) { - _systemData = new SystemData(); _updateAction = updateWidget; _dataType = type; @@ -30,102 +31,140 @@ internal sealed partial class DataManager : IDisposable private void GetMemoryData() { - lock (SystemData.MemStats) + lock (_systemData.MemoryStats) { - SystemData.MemStats.GetData(); + _systemData.MemoryStats.GetData(); } } private void GetNetworkData() { - lock (SystemData.NetStats) + lock (_systemData.NetworkStats) { - SystemData.NetStats.GetData(); + _systemData.NetworkStats.GetData(); } } private void GetGPUData() { - lock (SystemData.GPUStats) + lock (_systemData.GPUStats) { - SystemData.GPUStats.GetData(); + _systemData.GPUStats.GetData(); } } private void GetCPUData(bool includeTopProcesses) { - lock (SystemData.CpuStats) + lock (_systemData.CpuStats) { - SystemData.CpuStats.GetData(includeTopProcesses); + _systemData.CpuStats.GetData(includeTopProcesses); } } private void UpdateTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { - switch (_dataType) + var firstUpdateBlockSuffix = GetFirstUpdateBlockSuffix(); + var isTracked = firstUpdateBlockSuffix is not null && PerformanceMonitorCommandsProvider.CrashSentinel.BeginBlock(firstUpdateBlockSuffix); + + try { - case DataType.CPU: - case DataType.CpuWithTopProcesses: - { - // CPU - GetCPUData(_dataType == DataType.CpuWithTopProcesses); - break; - } + switch (_dataType) + { + case DataType.CPU: + case DataType.CpuWithTopProcesses: + { + // CPU + GetCPUData(_dataType == DataType.CpuWithTopProcesses); + break; + } - case DataType.GPU: - { - // gpu - GetGPUData(); - break; - } + case DataType.GPU: + { + // gpu + GetGPUData(); + break; + } - case DataType.Memory: - { - // memory - GetMemoryData(); - break; - } + case DataType.Memory: + { + // memory + GetMemoryData(); + break; + } - case DataType.Network: - { - // network - GetNetworkData(); - break; - } + case DataType.Network: + { + // network + GetNetworkData(); + break; + } + } + + if (isTracked) + { + PerformanceMonitorCommandsProvider.CrashSentinel.CompleteBlock(firstUpdateBlockSuffix!); + } + + _updateAction?.Invoke(); } + catch (Exception ex) + { + if (isTracked) + { + PerformanceMonitorCommandsProvider.CrashSentinel.CancelBlock(firstUpdateBlockSuffix!); + } - _updateAction?.Invoke(); + _updateTimer.Stop(); + if (!_updateFailureLogged) + { + _updateFailureLogged = true; + Microsoft.CmdPal.Common.CoreLogger.LogError($"Unexpected exception while updating performance monitor data for {_dataType}. Timer stopped.", ex); + } + } + } + + private string? GetFirstUpdateBlockSuffix() + { + return _dataType switch + { + DataType.CPU => "CPU.FirstUpdate", + DataType.CpuWithTopProcesses => "CPU.FirstUpdate", + DataType.GPU => "GPU.FirstUpdate", + DataType.Memory => "Memory.FirstUpdate", + DataType.Network => "Network.FirstUpdate", + _ => null, + }; } internal MemoryStats GetMemoryStats() { - lock (SystemData.MemStats) + lock (_systemData.MemoryStats) { - return SystemData.MemStats; + return _systemData.MemoryStats; } } internal NetworkStats GetNetworkStats() { - lock (SystemData.NetStats) + lock (_systemData.NetworkStats) { - return SystemData.NetStats; + return _systemData.NetworkStats; } } internal GPUStats GetGPUStats() { - lock (SystemData.GPUStats) + lock (_systemData.GPUStats) { - return SystemData.GPUStats; + return _systemData.GPUStats; } } internal CPUStats GetCPUStats() { - lock (SystemData.CpuStats) + lock (_systemData.CpuStats) { - return SystemData.CpuStats; + return _systemData.CpuStats; } } @@ -141,7 +180,6 @@ internal sealed partial class DataManager : IDisposable public void Dispose() { - _systemData.Dispose(); _updateTimer.Dispose(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs index 30cbb05de1..9c4aa4b57e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs @@ -10,7 +10,7 @@ using System.Text; namespace CoreWidgetProvider.Helpers; -internal sealed partial class GPUStats : IDisposable +internal sealed partial class GPUStats : PerformanceCounterSourceBase, IDisposable { // Performance counter category & counter names private const string GpuEngineCategoryName = "GPU Engine"; @@ -32,7 +32,7 @@ internal sealed partial class GPUStats : IDisposable private const string TemperatureUnavailable = "--"; // Batch read via category - single kernel transition per tick - private readonly PerformanceCounterCategory _gpuEngineCategory = new(GpuEngineCategoryName); + private readonly PerformanceCounterCategory? _gpuEngineCategory; // Discovered physical GPU IDs private readonly HashSet _knownPhysIds = []; @@ -41,6 +41,8 @@ internal sealed partial class GPUStats : IDisposable // Previous raw samples for computing cooked (delta-based) values private Dictionary _previousSamples = []; + private bool _gpuEnumerationFailureLogged; + private bool _gpuReadFailureLogged; public sealed class Data { @@ -57,47 +59,61 @@ internal sealed partial class GPUStats : IDisposable public GPUStats() { + _gpuEngineCategory = CreatePerformanceCounterCategory(GpuEngineCategoryName); + GetGPUPerfCounters(); LoadGPUsFromCounters(); } public void GetGPUPerfCounters() { - // There are really 4 different things we should be tracking the usage - // of. Similar to how the instance name ends with `3D`, the following - // suffixes are important. - // - // * `3D` - // * `VideoEncode` - // * `VideoDecode` - // * `VideoProcessing` - // - // We could totally put each of those sets of counters into their own - // set. That's what we should do, so that we can report the sum of those - // numbers as the total utilization, and then have them broken out in - // the card template and in the details metadata. - _knownPhysIds.Clear(); - - var instanceNames = _gpuEngineCategory.GetInstanceNames(); - - foreach (var instanceName in instanceNames) + if (_gpuEngineCategory is null) { - if (!instanceName.EndsWith(EngineType3D, StringComparison.InvariantCulture)) + return; + } + + try + { + // There are really 4 different things we should be tracking the usage + // of. Similar to how the instance name ends with `3D`, the following + // suffixes are important. + // + // * `3D` + // * `VideoEncode` + // * `VideoDecode` + // * `VideoProcessing` + // + // We could totally put each of those sets of counters into their own + // set. That's what we should do, so that we can report the sum of those + // numbers as the total utilization, and then have them broken out in + // the card template and in the details metadata. + _knownPhysIds.Clear(); + + var instanceNames = _gpuEngineCategory.GetInstanceNames(); + + foreach (var instanceName in instanceNames) { - continue; - } - - var counterKey = instanceName; - - // skip these values - GetKeyValueFromCounterKey(KeyPid, ref counterKey); - GetKeyValueFromCounterKey(KeyLuid, ref counterKey); - - if (int.TryParse(GetKeyValueFromCounterKey(KeyPhys, ref counterKey), out var phys)) - { - _knownPhysIds.Add(phys); + if (!instanceName.EndsWith(EngineType3D, StringComparison.InvariantCulture)) + { + continue; + } + + var counterKey = instanceName; + + // skip these values + GetKeyValueFromCounterKey(KeyPid, ref counterKey); + GetKeyValueFromCounterKey(KeyLuid, ref counterKey); + + if (int.TryParse(GetKeyValueFromCounterKey(KeyPhys, ref counterKey), out var phys)) + { + _knownPhysIds.Add(phys); + } } } + catch (Exception ex) + { + LogFailureOnce(ref _gpuEnumerationFailureLogged, "Failed while enumerating GPU performance counters.", ex); + } } public void LoadGPUsFromCounters() @@ -119,6 +135,11 @@ internal sealed partial class GPUStats : IDisposable public void GetData() { + if (_gpuEngineCategory is null) + { + return; + } + try { // Single batch read - one kernel transition for ALL GPU Engine instances @@ -183,9 +204,9 @@ internal sealed partial class GPUStats : IDisposable } } } - catch (Exception) + catch (Exception ex) { - // Ignore errors from ReadCategory (e.g., category not available). + LogFailureOnce(ref _gpuReadFailureLogged, "Failed while reading GPU performance counters.", ex); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs index bb371353f0..934c8afc19 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs @@ -10,13 +10,14 @@ using Windows.Win32; namespace CoreWidgetProvider.Helpers; -internal sealed partial class MemoryStats : IDisposable +internal sealed partial class MemoryStats : PerformanceCounterSourceBase, IDisposable { - private readonly PerformanceCounter _memCommitted = new("Memory", "Committed Bytes", string.Empty); - private readonly PerformanceCounter _memCached = new("Memory", "Cache Bytes", string.Empty); - private readonly PerformanceCounter _memCommittedLimit = new("Memory", "Commit Limit", string.Empty); - private readonly PerformanceCounter _memPoolPaged = new("Memory", "Pool Paged Bytes", string.Empty); - private readonly PerformanceCounter _memPoolNonPaged = new("Memory", "Pool Nonpaged Bytes", string.Empty); + private readonly PerformanceCounter? _memCommitted; + private readonly PerformanceCounter? _memCached; + private readonly PerformanceCounter? _memCommittedLimit; + private readonly PerformanceCounter? _memPoolPaged; + private readonly PerformanceCounter? _memPoolNonPaged; + private bool _memoryCounterReadFailureLogged; public float MemUsage { @@ -60,6 +61,15 @@ internal sealed partial class MemoryStats : IDisposable public List MemChartValues { get; set; } = new(); + public MemoryStats() + { + _memCommitted = CreatePerformanceCounter("Memory", "Committed Bytes"); + _memCached = CreatePerformanceCounter("Memory", "Cache Bytes"); + _memCommittedLimit = CreatePerformanceCounter("Memory", "Commit Limit"); + _memPoolPaged = CreatePerformanceCounter("Memory", "Pool Paged Bytes"); + _memPoolNonPaged = CreatePerformanceCounter("Memory", "Pool Nonpaged Bytes"); + } + public void GetData() { Windows.Win32.System.SystemInformation.MEMORYSTATUSEX memStatus = default; @@ -77,11 +87,18 @@ internal sealed partial class MemoryStats : IDisposable } } - MemCached = (ulong)_memCached.NextValue(); - MemCommitted = (ulong)_memCommitted.NextValue(); - MemCommitLimit = (ulong)_memCommittedLimit.NextValue(); - MemPagedPool = (ulong)_memPoolPaged.NextValue(); - MemNonPagedPool = (ulong)_memPoolNonPaged.NextValue(); + try + { + MemCached = (ulong)(_memCached?.NextValue() ?? 0); + MemCommitted = (ulong)(_memCommitted?.NextValue() ?? 0); + MemCommitLimit = (ulong)(_memCommittedLimit?.NextValue() ?? 0); + MemPagedPool = (ulong)(_memPoolPaged?.NextValue() ?? 0); + MemNonPagedPool = (ulong)(_memPoolNonPaged?.NextValue() ?? 0); + } + catch (Exception ex) + { + LogFailureOnce(ref _memoryCounterReadFailureLogged, "Failed while reading memory performance counters.", ex); + } } public string CreateMemImageUrl() @@ -91,10 +108,10 @@ internal sealed partial class MemoryStats : IDisposable public void Dispose() { - _memCommitted.Dispose(); - _memCached.Dispose(); - _memCommittedLimit.Dispose(); - _memPoolPaged.Dispose(); - _memPoolNonPaged.Dispose(); + _memCommitted?.Dispose(); + _memCached?.Dispose(); + _memCommittedLimit?.Dispose(); + _memPoolPaged?.Dispose(); + _memPoolNonPaged?.Dispose(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs index d5dc3ac15f..00e4b1e529 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs @@ -6,12 +6,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using Microsoft.CmdPal.Common; namespace CoreWidgetProvider.Helpers; -internal sealed partial class NetworkStats : IDisposable +internal sealed partial class NetworkStats : PerformanceCounterSourceBase, IDisposable { private readonly Dictionary> _networkCounters = new(); + private bool _networkCounterReadFailureLogged; private Dictionary NetworkUsages { get; set; } = new(); @@ -42,17 +44,44 @@ internal sealed partial class NetworkStats : IDisposable private void InitNetworkPerfCounters() { - var perfCounterCategory = new PerformanceCounterCategory("Network Interface"); - var instanceNames = perfCounterCategory.GetInstanceNames(); - foreach (var instanceName in instanceNames) + try { - var instanceCounters = new List(); - instanceCounters.Add(new PerformanceCounter("Network Interface", "Bytes Sent/sec", instanceName)); - instanceCounters.Add(new PerformanceCounter("Network Interface", "Bytes Received/sec", instanceName)); - instanceCounters.Add(new PerformanceCounter("Network Interface", "Current Bandwidth", instanceName)); - _networkCounters.Add(instanceName, instanceCounters); - NetChartValues.Add(instanceName, new List()); - NetworkUsages.Add(instanceName, new Data()); + var perfCounterCategory = CreatePerformanceCounterCategory("Network Interface"); + if (perfCounterCategory is null) + { + return; + } + + var instanceNames = perfCounterCategory.GetInstanceNames(); + foreach (var instanceName in instanceNames) + { + try + { + var bytesSent = CreatePerformanceCounter("Network Interface", "Bytes Sent/sec", instanceName, logFailure: false); + var bytesReceived = CreatePerformanceCounter("Network Interface", "Bytes Received/sec", instanceName, logFailure: false); + var currentBandwidth = CreatePerformanceCounter("Network Interface", "Current Bandwidth", instanceName, logFailure: false); + if (bytesSent is null || bytesReceived is null || currentBandwidth is null) + { + bytesSent?.Dispose(); + bytesReceived?.Dispose(); + currentBandwidth?.Dispose(); + continue; + } + + var instanceCounters = new List { bytesSent, bytesReceived, currentBandwidth }; + _networkCounters.Add(instanceName, instanceCounters); + NetChartValues.Add(instanceName, new List()); + NetworkUsages.Add(instanceName, new Data()); + } + catch (Exception) + { + // Skip interfaces whose counters cannot be initialized. + } + } + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to initialize network performance counters.", ex); } } @@ -88,9 +117,9 @@ internal sealed partial class NetworkStats : IDisposable maxUsage = usage; } } - catch (Exception) + catch (Exception ex) { - // Log.Error(ex, "Error getting network data."); + LogFailureOnce(ref _networkCounterReadFailureLogged, "Failed while reading network performance counters.", ex); } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/PerformanceCounterSourceBase.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/PerformanceCounterSourceBase.cs new file mode 100644 index 0000000000..e370ad1f81 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/PerformanceCounterSourceBase.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using Microsoft.CmdPal.Common; + +namespace CoreWidgetProvider.Helpers; + +internal abstract class PerformanceCounterSourceBase +{ + protected PerformanceCounter? CreatePerformanceCounter(string categoryName, string counterName, string instanceName = "", bool readOnly = true, bool logFailure = true) + { + try + { + return new PerformanceCounter(categoryName, counterName, instanceName, readOnly); + } + catch (Exception ex) + { + if (logFailure) + { + var suffix = string.IsNullOrEmpty(instanceName) ? string.Empty : $@"\{instanceName}"; + CoreLogger.LogError($@"Failed to initialize performance counter '{categoryName}\{counterName}{suffix}'.", ex); + } + + return null; + } + } + + protected PerformanceCounterCategory? CreatePerformanceCounterCategory(string categoryName, bool logFailure = true) + { + try + { + if (!PerformanceCounterCategory.Exists(categoryName)) + { + if (logFailure) + { + CoreLogger.LogError($@"Performance counter category '{categoryName}' does not exist on this system."); + } + + return null; + } + + return new PerformanceCounterCategory(categoryName); + } + catch (Exception ex) + { + if (logFailure) + { + CoreLogger.LogError($@"Failed to initialize performance counter category '{categoryName}'.", ex); + } + + return null; + } + } + + protected void LogFailureOnce(ref bool hasLoggedFailure, string message, Exception ex) + { + if (!hasLoggedFailure) + { + hasLoggedFailure = true; + CoreLogger.LogError(message, ex); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs index 52d0b2c536..c83293a074 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs @@ -3,24 +3,53 @@ // See the LICENSE file in the project root for more information. using System; +using Microsoft.CmdPal.Ext.PerformanceMonitor; namespace CoreWidgetProvider.Helpers; -internal sealed partial class SystemData : IDisposable +internal sealed partial class SystemData { - public static MemoryStats MemStats { get; set; } = new MemoryStats(); + public static SystemData Shared { get; } = new(); - public static NetworkStats NetStats { get; set; } = new NetworkStats(); + private readonly Lazy _memoryStats = new(() => CreateGuarded("Memory.Initialize", static () => new MemoryStats())); + private readonly Lazy _networkStats = new(() => CreateGuarded("Network.Initialize", static () => new NetworkStats())); + private readonly Lazy _gpuStats = new(() => CreateGuarded("GPU.Initialize", static () => new GPUStats())); + private readonly Lazy _cpuStats = new(() => CreateGuarded("CPU.Initialize", static () => new CPUStats())); - public static GPUStats GPUStats { get; set; } = new GPUStats(); + public MemoryStats MemoryStats => _memoryStats.Value; - public static CPUStats CpuStats { get; set; } = new CPUStats(); + public NetworkStats NetworkStats => _networkStats.Value; - public SystemData() + public GPUStats GPUStats => _gpuStats.Value; + + public CPUStats CpuStats => _cpuStats.Value; + + private SystemData() { } - public void Dispose() + private static T CreateGuarded(string blockSuffix, Func factory) { + var isTracked = PerformanceMonitorCommandsProvider.CrashSentinel.BeginBlock(blockSuffix); + + try + { + var value = factory(); + if (isTracked) + { + PerformanceMonitorCommandsProvider.CrashSentinel.CompleteBlock(blockSuffix); + } + + return value; + } + catch + { + if (isTracked) + { + PerformanceMonitorCommandsProvider.CrashSentinel.CancelBlock(blockSuffix); + } + + throw; + } } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs index 6e7c66cdc8..3f00a99395 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs @@ -2,7 +2,11 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; +using System.Threading; using CoreWidgetProvider.Helpers; +using Microsoft.CmdPal.Common; +using Microsoft.CmdPal.Common.Helpers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -10,30 +14,129 @@ namespace Microsoft.CmdPal.Ext.PerformanceMonitor; public partial class PerformanceMonitorCommandsProvider : CommandProvider { - private readonly ICommandItem[] _commands; - private readonly ICommandItem _band; + public const string ProviderIdValue = "PerformanceMonitor"; + public const string ProviderLoadGuardBlockId = ProviderIdValue + ".ProviderLoad"; + public const string PageIdValue = "com.microsoft.cmdpal.performanceWidget"; - public PerformanceMonitorCommandsProvider() + internal static ProviderCrashSentinel CrashSentinel { get; } = new(ProviderIdValue); + + private readonly Lock _stateLock = new(); + private ICommandItem[] _commands = []; + private ICommandItem _band = new CommandItem(); + private PerformanceWidgetsPage? _mainPage; + private PerformanceWidgetsPage? _bandPage; + private bool _softDisabled; + + public PerformanceMonitorCommandsProvider(bool softDisabled = false) { DisplayName = Resources.GetResource("Performance_Monitor_Title"); - Id = "PerformanceMonitor"; + Id = ProviderIdValue; Icon = Icons.PerformanceMonitorIcon; - var page = new PerformanceWidgetsPage(false); - var band = new PerformanceWidgetsPage(true); - _band = new CommandItem(band) { Title = DisplayName }; - _commands = [ - new CommandItem(page) { Title = DisplayName }, - ]; + if (softDisabled) + { + SetDisabledState(); + } + else + { + SetEnabledState(); + } } public override ICommandItem[] TopLevelCommands() { - return _commands; + lock (_stateLock) + { + return _commands; + } } public override ICommandItem[]? GetDockBands() { - return new ICommandItem[] { _band }; + lock (_stateLock) + { + return [_band]; + } + } + + public bool TryReactivateImmediately() + { + lock (_stateLock) + { + if (!_softDisabled) + { + return true; + } + + try + { + CrashSentinel.ClearProviderState(); + SetEnabledState(); + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to reactivate Performance Monitor in the current session. Keeping placeholder pages loaded.", ex); + return false; + } + } + + RaiseItemsChanged(); + return true; + } + + public override void Dispose() + { + lock (_stateLock) + { + DisposeActivePages(); + } + + GC.SuppressFinalize(this); + base.Dispose(); + } + + private void SetDisabledState() + { + DisposeActivePages(); + + var page = new PerformanceMonitorDisabledPage(this); + var band = new PerformanceMonitorDisabledPage(this); + _band = new CommandItem(band) + { + Title = Resources.GetResource("Performance_Monitor_Disabled_Band_Title"), + Subtitle = DisplayName, + }; + _commands = + [ + new CommandItem(page) + { + Title = DisplayName, + Subtitle = Resources.GetResource("Performance_Monitor_Disabled_Subtitle"), + }, + ]; + _softDisabled = true; + } + + private void SetEnabledState() + { + DisposeActivePages(); + + _mainPage = new PerformanceWidgetsPage(false); + _bandPage = new PerformanceWidgetsPage(true); + _band = new CommandItem(_bandPage) { Title = DisplayName }; + _commands = + [ + new CommandItem(_mainPage) { Title = DisplayName }, + ]; + _softDisabled = false; + } + + private void DisposeActivePages() + { + _mainPage?.Dispose(); + _mainPage = null; + + _bandPage?.Dispose(); + _bandPage = null; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorDisabledPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorDisabledPage.cs new file mode 100644 index 0000000000..f2ae115297 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorDisabledPage.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CoreWidgetProvider.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.PerformanceMonitor; + +internal sealed partial class PerformanceMonitorDisabledPage : ContentPage +{ + private readonly MarkdownContent _content; + + public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider) + { + Id = PerformanceMonitorCommandsProvider.PageIdValue; + Name = Resources.GetResource("Performance_Monitor_Disabled_Title"); + Title = Resources.GetResource("Performance_Monitor_Title"); + Icon = Icons.PerformanceMonitorIcon; + + _content = new MarkdownContent(Resources.GetResource("Performance_Monitor_Disabled_Body")); + Commands = + [ + new CommandContextItem(new ReactivatePerformanceMonitorCommand(provider)), + ]; + } + + public override IContent[] GetContent() + { + return [_content]; + } + + private sealed partial class ReactivatePerformanceMonitorCommand(PerformanceMonitorCommandsProvider provider) : InvokableCommand + { + private readonly PerformanceMonitorCommandsProvider _provider = provider; + + public override string Id => "com.microsoft.cmdpal.performanceWidget.reactivate"; + + public override IconInfo Icon => Icons.NavigateForwardIcon; + + public override string Name => Resources.GetResource("Performance_Monitor_Reenable_Title"); + + public override ICommandResult Invoke() + { + if (_provider.TryReactivateImmediately()) + { + return CommandResult.ShowToast(new ToastArgs + { + Message = Resources.GetResource("Performance_Monitor_Reenable_Success"), + Result = CommandResult.GoHome(), + }); + } + + return CommandResult.ShowToast(Resources.GetResource("Performance_Monitor_Reenable_Failed")); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw index bcbbbfc001..1d49265baf 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw @@ -189,6 +189,32 @@ Performance monitor + + Performance monitor is temporarily disabled + + + Temporarily disabled after repeated startup crashes + + + Perf disabled + + + Performance monitor was temporarily disabled after repeated crashes while initializing or reading performance counters. + + Select **Re-enable now** to clear the crash guard and restore the Performance Monitor command and dock band in the current session. + + + Re-enable now + + + Clear the crash guard and restore Performance Monitor in this session + + + Performance monitor has been restored for this session. + + + Performance monitor could not be restored right now. It will stay disabled for this session. + CPU Usage