diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 0ad177e..a9a7f01 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -402,33 +402,12 @@ private async Task LoadViewModelsAsync() this.LogDebug($"ProcessViewModel fallback (LoadProcesses only) completed after exception, process count: {this.processViewModel.Processes?.Count ?? 0}, filtered count: {this.processViewModel.FilteredProcesses?.Count ?? 0}"); } - this.LogDebug("About to load PowerPlanViewModel..."); - var powerPlanTask = this.powerPlanViewModel.LoadPowerPlans(); - var powerPlanResult = await Task.WhenAny(powerPlanTask, Task.Delay(5000)); // 5 second timeout - if (powerPlanResult != powerPlanTask) - { - throw new TimeoutException("PowerPlanViewModel.LoadPowerPlans() timed out after 5 seconds"); - } - await powerPlanTask; // Ensure we get any exceptions - this.LogDebug("PowerPlanViewModel loaded successfully"); - - this.LogDebug("Skipping optional diagnostics initialization until the diagnostics page is opened."); - - this.LogDebug("About to load SystemTweaksViewModel..."); - var systemTweaksTask = this.systemTweaksViewModel.LoadCommand.ExecuteAsync(null); - var systemTweaksResult = await Task.WhenAny(systemTweaksTask, Task.Delay(5000)); // 5 second timeout - if (systemTweaksResult != systemTweaksTask) - { - throw new TimeoutException("SystemTweaksViewModel.LoadCommand.ExecuteAsync() timed out after 5 seconds"); - } - await systemTweaksTask; // Ensure we get any exceptions - this.LogDebug("SystemTweaksViewModel loaded successfully"); + this.LogDebug("Skipping inactive view initialization until each page is opened."); // Initialize keyboard shortcuts after window is loaded this.Loaded += this.OnWindowLoaded; this.LogDebug("Keyboard shortcuts event handler attached"); - // The association view model loads its data automatically in its constructor this.LogDebug("=== LoadViewModelsAsync completed successfully ==="); } catch (Exception ex) @@ -1385,8 +1364,13 @@ private AppActivityState GetForegroundActivityState() return AppActivityState.ForegroundProcessView; } - return this.PerformanceViewControl.Visibility == Visibility.Visible - ? AppActivityState.ForegroundDiagnosticsView + if (this.PerformanceViewControl.Visibility == Visibility.Visible) + { + return AppActivityState.ForegroundDiagnosticsView; + } + + return this.PowerPlanViewControl.Visibility == Visibility.Visible + ? AppActivityState.ForegroundPowerPlanView : AppActivityState.ForegroundOtherTab; } @@ -1419,7 +1403,7 @@ private void ApplyAppRefreshPolicy(AppActivityState state) if (decision.PowerPlanUiRefreshEnabled) { - this.powerPlanViewModel.ResumeAutoRefresh(refreshImmediately: state != AppActivityState.ForegroundOtherTab); + this.powerPlanViewModel.ResumeAutoRefresh(); } else { @@ -1887,12 +1871,42 @@ private void ApplySectionVisibility(string tag) { "Process" => AppActivityState.ForegroundProcessView, "Performance" => AppActivityState.ForegroundDiagnosticsView, + "Power" => AppActivityState.ForegroundPowerPlanView, _ => AppActivityState.ForegroundOtherTab, }; this.ApplyAppRefreshPolicy(activityState); } + private async Task InitializeSectionAsync(string tag) + { + if (!this.initializedSections.Add(tag)) + { + return; + } + + try + { + switch (tag) + { + case "Rules": + await this.associationViewModel.InitializeAsync(); + break; + case "Tweaks": + await this.systemTweaksViewModel.LoadAsync(); + break; + case "Settings": + await this.settingsViewModel.InitializeAsync(); + break; + } + } + catch + { + this.initializedSections.Remove(tag); + throw; + } + } + private void NavMenuItem_Click(object sender, RoutedEventArgs e) { TaskSafety.FireAndForget(this.NavMenuItem_ClickAsync(sender, e), ex => @@ -1942,8 +1956,19 @@ private async Task NavMenuItem_ClickAsync(object sender, RoutedEventArgs e) this.GetPerformanceViewModel(); } + if (!string.Equals(tag, "Logs", StringComparison.Ordinal)) + { + await this.logViewerViewModel.SetActiveAsync(false); + } + + await this.InitializeSectionAsync(tag); this.ApplySectionVisibility(tag); + if (string.Equals(tag, "Logs", StringComparison.Ordinal)) + { + await this.logViewerViewModel.SetActiveAsync(true); + } + if (string.Equals(tag, "Performance", StringComparison.Ordinal)) { this.TryShowPerformanceIntro(); diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index eec3de9..a37a279 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -64,6 +64,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow private readonly bool skipProcessMonitoringDuringStartup = false; private bool isPerformingShutdown = false; private readonly NavigationBehavior navigationBehavior = new(); + private readonly HashSet initializedSections = new(StringComparer.Ordinal); private bool isPerformanceIntroVisible = false; private double previousAppContentOpacity = 1; private TaskCompletionSource? unsavedSettingsDialogCompletionSource; diff --git a/Services/AppRefreshPolicy.cs b/Services/AppRefreshPolicy.cs index 91fdb85..9a0727c 100644 --- a/Services/AppRefreshPolicy.cs +++ b/Services/AppRefreshPolicy.cs @@ -4,6 +4,7 @@ public enum AppActivityState { ForegroundProcessView, ForegroundDiagnosticsView, + ForegroundPowerPlanView, ForegroundOtherTab, Minimized, TrayHidden, @@ -33,13 +34,20 @@ public static AppRefreshDecision Evaluate(AppActivityState state) ImmediateProcessRefresh: true, VirtualizedPreloadEnabled: true, PerformanceUiMonitoringEnabled: false, - PowerPlanUiRefreshEnabled: true, + PowerPlanUiRefreshEnabled: false, BackgroundAutomationEnabled: true), AppActivityState.ForegroundDiagnosticsView => new AppRefreshDecision( ProcessUiRefreshEnabled: false, ImmediateProcessRefresh: false, VirtualizedPreloadEnabled: false, PerformanceUiMonitoringEnabled: true, + PowerPlanUiRefreshEnabled: false, + BackgroundAutomationEnabled: true), + AppActivityState.ForegroundPowerPlanView => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: false, PowerPlanUiRefreshEnabled: true, BackgroundAutomationEnabled: true), AppActivityState.ForegroundOtherTab => new AppRefreshDecision( @@ -47,7 +55,7 @@ public static AppRefreshDecision Evaluate(AppActivityState state) ImmediateProcessRefresh: false, VirtualizedPreloadEnabled: false, PerformanceUiMonitoringEnabled: false, - PowerPlanUiRefreshEnabled: true, + PowerPlanUiRefreshEnabled: false, BackgroundAutomationEnabled: true), AppActivityState.Minimized or AppActivityState.TrayHidden => new AppRefreshDecision( ProcessUiRefreshEnabled: false, diff --git a/Services/EnhancedLoggingService.cs b/Services/EnhancedLoggingService.cs index 7ac3482..93e33e5 100644 --- a/Services/EnhancedLoggingService.cs +++ b/Services/EnhancedLoggingService.cs @@ -19,6 +19,7 @@ public class EnhancedLoggingService : IEnhancedLoggingService, IDisposable private readonly System.Threading.Timer flushTimer; private readonly string logDirectory; private string currentLogFilePath; + private int flushScheduled; private bool isInitialized; private bool disposed; @@ -43,8 +44,7 @@ public EnhancedLoggingService(ILogger logger, IApplicati this.logDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ThreadPilot", "Logs"); this.currentLogFilePath = this.GetCurrentLogFilePath(); - // Create flush timer (flush every 5 seconds) - this.flushTimer = new System.Threading.Timer(this.FlushLogs, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + this.flushTimer = new System.Threading.Timer(this.FlushLogs, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); } public async Task InitializeAsync() @@ -228,15 +228,22 @@ private async Task LogStructuredEventAsync(string category, string message, LogL this.logQueue.Enqueue(logEntry); - // Force immediate flush for errors and critical events + // Force immediate flush for errors and critical events. if (level >= LogLevel.Error) { + this.flushTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + Interlocked.Exchange(ref this.flushScheduled, 0); await this.FlushLogsAsync(); } + else if (Interlocked.Exchange(ref this.flushScheduled, 1) == 0) + { + this.flushTimer.Change(TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan); + } } private void FlushLogs(object? state) { + Interlocked.Exchange(ref this.flushScheduled, 0); TaskSafety.FireAndForget(this.FlushLogsAsync(), ex => { this.logger.LogWarning(ex, "Periodic log flush failed"); diff --git a/Services/PowerPlanService.cs b/Services/PowerPlanService.cs index e823a4b..ebecf8c 100644 --- a/Services/PowerPlanService.cs +++ b/Services/PowerPlanService.cs @@ -16,7 +16,7 @@ public class PowerPlanService : IPowerPlanService private static readonly Lazy powerPlansPath = new(GetPowerPlansPath); private static readonly string powerCfgExecutablePath = Path.Combine(Environment.SystemDirectory, "powercfg.exe"); private static readonly TimeSpan powerCfgTimeout = TimeSpan.FromSeconds(20); - private static readonly Regex powerSchemeRegex = new(@"^Power Scheme GUID: (.*?) \((.*)\)(?:\s+\*)?$", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex powerSchemeRegex = new(@"^Power Scheme GUID:\s*([0-9a-fA-F-]+)[ \t]+\((.*)\)[ \t]*(\*)?[ \t]*\r?$", RegexOptions.Multiline | RegexOptions.Compiled); private static readonly Regex pathTraversalRegex = new(@"(^|[\\/])\.\.([\\/]|$)", RegexOptions.Compiled); private static string PowerPlansPath => powerPlansPath.Value; @@ -102,8 +102,6 @@ private static string GetPowerPlansPath() public async Task> GetPowerPlansAsync() { var powerPlans = new ObservableCollection(); - var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); - var result = await this.RunPowerCfgAsync("/list").ConfigureAwait(false); var matches = powerSchemeRegex.Matches(result.StandardOutput); @@ -116,7 +114,7 @@ public async Task> GetPowerPlansAsync() { Guid = guid, Name = name, - IsActive = guid == activePlan?.Guid, + IsActive = match.Groups[3].Success, IsCustomPlan = false, }; diff --git a/Services/ProcessMonitorService.cs b/Services/ProcessMonitorService.cs index 8d8366d..51740cc 100644 --- a/Services/ProcessMonitorService.cs +++ b/Services/ProcessMonitorService.cs @@ -35,12 +35,12 @@ public class ProcessMonitorService : IProcessMonitorService private int fallbackPollingIntervalMs = 5000; // Default 5 seconds private int currentFallbackPollingIntervalMs = 5000; private int idlePollingMultiplier = 1; - private readonly int wmiRetryDelayMs = 10000; // 10 seconds private const int MaxIdlePollingMultiplier = 6; private bool enableWmiMonitoring = true; private bool enableFallbackPolling = true; private int isFallbackPollingInProgress; private int isWmiRecoveryInProgress; + private int wmiRecoveryFailureCount; private DateTime lastWmiRetryAttemptUtc = DateTime.MinValue; public event EventHandler? ProcessStarted; @@ -106,6 +106,8 @@ public async Task StartMonitoringAsync() if (!wmiStarted && this.enableFallbackPolling) { + this.lastWmiRetryAttemptUtc = DateTime.UtcNow; + this.wmiRecoveryFailureCount = 0; // Fall back to polling if WMI is not available this.StartFallbackPolling(); } @@ -172,10 +174,14 @@ public async Task StopMonitoringAsync() public async Task> GetRunningProcessesAsync() { + if (this.isMonitoring) + { + return this.runningProcesses.Values.ToArray(); + } + try { - var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - return processes; + return await this.processService.GetProcessesAsync().ConfigureAwait(false); } catch (Exception) { @@ -200,7 +206,7 @@ private async Task InitializeProcessListAsync() { try { - var processes = await this.GetRunningProcessesAsync().ConfigureAwait(false); + var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); this.runningProcesses.Clear(); foreach (var process in processes) @@ -256,6 +262,8 @@ await Task.Run(() => }).ConfigureAwait(false); this.isWmiAvailable = true; + this.wmiRecoveryFailureCount = 0; + this.lastWmiRetryAttemptUtc = DateTime.MinValue; // Prefer WMI when available to reduce polling overhead if (this.isFallbackPollingActive) @@ -314,6 +322,8 @@ private void OnWmiWatcherStopped(object sender, StoppedEventArgs e) } this.isWmiAvailable = false; + this.wmiRecoveryFailureCount = 0; + this.lastWmiRetryAttemptUtc = DateTime.UtcNow; this.OnMonitoringStatusChanged($"WMI watcher stopped ({e.Status})"); if (this.enableFallbackPolling && !this.isFallbackPollingActive) @@ -461,7 +471,7 @@ private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) return; } - var currentProcesses = await this.GetRunningProcessesAsync().ConfigureAwait(false); + var currentProcesses = await this.processService.GetProcessesAsync().ConfigureAwait(false); var detectedChanges = 0; this.pollBuffer.Clear(); @@ -559,7 +569,7 @@ private async Task TryRecoverWmiMonitoringAsync() } var now = DateTime.UtcNow; - if ((now - this.lastWmiRetryAttemptUtc).TotalMilliseconds < this.wmiRetryDelayMs) + if (now - this.lastWmiRetryAttemptUtc < GetWmiRetryDelay(this.wmiRecoveryFailureCount)) { return; } @@ -576,8 +586,13 @@ private async Task TryRecoverWmiMonitoringAsync() var recovered = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); if (recovered) { + this.wmiRecoveryFailureCount = 0; this.OnMonitoringStatusChanged("WMI monitoring recovered successfully"); } + else + { + this.wmiRecoveryFailureCount++; + } } finally { @@ -585,6 +600,15 @@ private async Task TryRecoverWmiMonitoringAsync() } } + internal static TimeSpan GetWmiRetryDelay(int failureCount) => + failureCount switch + { + <= 0 => TimeSpan.FromSeconds(10), + 1 => TimeSpan.FromSeconds(30), + 2 => TimeSpan.FromMinutes(1), + _ => TimeSpan.FromMinutes(5), + }; + private void OnMonitoringStatusChanged(string? message = null, Exception? error = null) { this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs( @@ -606,6 +630,8 @@ public void UpdateSettings() var previousFallbackEnabled = this.enableFallbackPolling; this.UpdateMonitoringSettings(); + this.wmiRecoveryFailureCount = 0; + this.lastWmiRetryAttemptUtc = DateTime.MinValue; if (!this.isMonitoring) { diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index db51921..140484f 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -20,6 +20,7 @@ public class ProcessService : IProcessService private readonly ConcurrentDictionary cpuSamples = new(); private readonly ConcurrentDictionary cpuSetHandlers = new(); + private readonly ConcurrentDictionary processMetadata = new(); private readonly ILogger? logger; private readonly ISecurityService? securityService; private readonly IForegroundProcessService? foregroundProcessService; @@ -132,6 +133,8 @@ private List EnumerateProcessModels(Func? filt return models.OrderBy(process => process.Name).ToList(); } + private sealed record ProcessMetadata(DateTime StartTimeUtc, string ExecutablePath); + private sealed class CpuSample { public CpuSample(TimeSpan totalProcessorTime, DateTime timestamp) @@ -276,8 +279,18 @@ private ProcessModel CreateProcessModel(Process process, int? foregroundProcessI } } + DateTime? startTimeUtc = null; if (!terminated) { + try + { + startTimeUtc = process.StartTime.ToUniversalTime(); + } + catch (Exception ex) when (IsPassiveProcessAccessException(ex) || IsTerminatedProcessException(ex)) + { + // Start time is used only to validate static metadata against PID reuse. + } + try { model.MemoryUsage = process.PrivateMemorySize64; @@ -358,25 +371,38 @@ private ProcessModel CreateProcessModel(Process process, int? foregroundProcessI if (!terminated) { - try + if (startTimeUtc.HasValue && + this.processMetadata.TryGetValue(model.ProcessId, out var cachedMetadata) && + cachedMetadata.StartTimeUtc == startTimeUtc.Value) { - model.ExecutablePath = process.MainModule?.FileName ?? string.Empty; + model.ExecutablePath = cachedMetadata.ExecutablePath; } - catch (Exception ex) when (IsAccessDeniedException(ex)) - { - accessDenied = true; - model.ExecutablePath = string.Empty; - this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); - } - catch (Exception ex) when (IsPassiveProcessAccessException(ex)) - { - accessDenied = true; - model.ExecutablePath = string.Empty; - this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); - } - catch (Exception ex) when (IsTerminatedProcessException(ex)) + else { - terminated = true; + try + { + model.ExecutablePath = process.MainModule?.FileName ?? string.Empty; + if (startTimeUtc.HasValue && !string.IsNullOrWhiteSpace(model.ExecutablePath)) + { + this.processMetadata[model.ProcessId] = new ProcessMetadata(startTimeUtc.Value, model.ExecutablePath); + } + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + model.ExecutablePath = string.Empty; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsPassiveProcessAccessException(ex)) + { + accessDenied = true; + model.ExecutablePath = string.Empty; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } } } } @@ -911,8 +937,9 @@ private static string TryGetProcessName(Process process, int processId) private void CleanupProcessResources(int processId) { - // Remove CPU samples + // Remove process-scoped samples and static metadata. this.cpuSamples.TryRemove(processId, out _); + this.processMetadata.TryRemove(processId, out _); // Dispose and remove CPU Set handler if (this.cpuSetHandlers.TryRemove(processId, out var handler)) diff --git a/Services/SystemTrayStatusUpdater.cs b/Services/SystemTrayStatusUpdater.cs index f214d42..4fa050b 100644 --- a/Services/SystemTrayStatusUpdater.cs +++ b/Services/SystemTrayStatusUpdater.cs @@ -71,7 +71,7 @@ public async Task UpdateStatusAsync(ISystemTrayService systemTrayService, private async Task UpdatePowerPlanMenuAsync(ISystemTrayService systemTrayService) { var powerPlans = await this.powerPlanService.GetPowerPlansAsync().ConfigureAwait(false); - var activePowerPlan = await this.powerPlanService.GetActivePowerPlan().ConfigureAwait(false); + var activePowerPlan = powerPlans.FirstOrDefault(plan => plan.IsActive); systemTrayService.UpdatePowerPlans(powerPlans, activePowerPlan); return activePowerPlan; } diff --git a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs index 494df32..64678a6 100644 --- a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs @@ -5,9 +5,10 @@ namespace ThreadPilot.Core.Tests public sealed class AppRefreshPolicyTests { [Theory] - [InlineData(AppActivityState.ForegroundProcessView, true, true, true, false, true, true)] - [InlineData(AppActivityState.ForegroundDiagnosticsView, false, false, false, true, true, true)] - [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, false, true, true)] + [InlineData(AppActivityState.ForegroundProcessView, true, true, true, false, false, true)] + [InlineData(AppActivityState.ForegroundDiagnosticsView, false, false, false, true, false, true)] + [InlineData(AppActivityState.ForegroundPowerPlanView, false, false, false, false, true, true)] + [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, false, false, true)] [InlineData(AppActivityState.Minimized, false, false, false, false, false, true)] [InlineData(AppActivityState.TrayHidden, false, false, false, false, false, true)] public void Evaluate_ReturnsExpectedRefreshDecision( diff --git a/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs index 00d1c83..cd5395c 100644 --- a/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs +++ b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs @@ -26,6 +26,26 @@ public async Task InitializeAsync_LoadsVisibleThreadPilotActivityEntries() Assert.Equal("Guid: game", entry.Details); } + [Fact] + public async Task ActivityEntryAdded_UpdatesOnlyWhileViewerIsActive() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(audit); + + await audit.LogInfoAsync("Process", "Hidden entry"); + Assert.Empty(viewModel.LogEntries); + + await viewModel.SetActiveAsync(true); + await audit.LogInfoAsync("Process", "Visible entry"); + + Assert.Equal(2, viewModel.LogEntries.Count); + Assert.Equal("Visible entry", viewModel.LogEntries[0].Message); + + await viewModel.SetActiveAsync(false); + await audit.LogInfoAsync("Process", "Another hidden entry"); + Assert.Equal(2, viewModel.LogEntries.Count); + } + [Fact] public async Task ClearLogsCommand_ClearsOnlyVisibleActivityDisplayWithoutAddingNoise() { diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs index 9982d06..1ecb058 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs @@ -50,6 +50,30 @@ public async Task GetActivePowerPlan_PreservesParenthesesInDisplayName() Assert.Equal("Ultimate Performance (AMD)", result.Name); } + [Fact] + public async Task GetPowerPlansAsync_ParsesActiveMarkerWithOnePowerCfgCall() + { + const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + const string otherGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult( + 0, + $"Power Scheme GUID: {activeGuid} (Balanced) *{Environment.NewLine}" + + $"Power Scheme GUID: {otherGuid} (Ultimate Performance (AMD))", + string.Empty), + }; + var service = CreateService(runner); + + var plans = await service.GetPowerPlansAsync(); + + Assert.Equal(2, plans.Count); + Assert.True(plans.Single(plan => plan.Guid == activeGuid).IsActive); + Assert.False(plans.Single(plan => plan.Guid == otherGuid).IsActive); + Assert.Equal("Ultimate Performance (AMD)", plans.Single(plan => plan.Guid == otherGuid).Name); + Assert.Equal(new[] { "/list" }, Assert.Single(runner.Invocations).Arguments); + } + [Fact] public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorServiceTests.cs new file mode 100644 index 0000000..8d16f5b --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorServiceTests.cs @@ -0,0 +1,47 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.Concurrent; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessMonitorServiceTests + { + [Fact] + public async Task GetRunningProcessesAsync_WhileMonitoring_ReusesMonitorSnapshot() + { + var processService = new Mock(MockBehavior.Strict); + var settingsService = new Mock(MockBehavior.Loose); + settingsService.SetupGet(service => service.Settings).Returns(new ApplicationSettingsModel()); + using var monitor = new ProcessMonitorService( + processService.Object, + settingsService.Object, + NullLogger.Instance); + var process = new ProcessModel { ProcessId = 42, Name = "cached" }; + var runningProcesses = (ConcurrentDictionary)typeof(ProcessMonitorService) + .GetField("runningProcesses", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(monitor)!; + runningProcesses[process.ProcessId] = process; + typeof(ProcessMonitorService) + .GetField("isMonitoring", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(monitor, true); + + var snapshot = await monitor.GetRunningProcessesAsync(); + + Assert.Same(process, Assert.Single(snapshot)); + processService.Verify(service => service.GetProcessesAsync(), Times.Never); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(1, 30)] + [InlineData(2, 60)] + [InlineData(3, 300)] + [InlineData(10, 300)] + public void GetWmiRetryDelay_BacksOffAfterFailures(int failureCount, int expectedSeconds) + { + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), ProcessMonitorService.GetWmiRetryDelay(failureCount)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs new file mode 100644 index 0000000..1aa6a9e --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessPowerPlanAssociationViewModelTests.cs @@ -0,0 +1,35 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class ProcessPowerPlanAssociationViewModelTests + { + [Fact] + public void Constructor_IgnoresConfigurationReloadUntilViewIsInitialized() + { + var associations = new Mock(MockBehavior.Loose); + var powerPlans = new Mock(MockBehavior.Strict); + var processes = new Mock(MockBehavior.Strict); + var monitor = new Mock(MockBehavior.Loose); + var masks = new Mock(MockBehavior.Strict); + _ = new ProcessPowerPlanAssociationViewModel( + NullLogger.Instance, + associations.Object, + powerPlans.Object, + processes.Object, + monitor.Object, + masks.Object); + + associations.Raise( + service => service.ConfigurationChanged += null, + new ConfigurationChangedEventArgs("Loaded")); + + powerPlans.Verify(service => service.GetPowerPlansAsync(), Times.Never); + processes.Verify(service => service.GetProcessesAsync(), Times.Never); + masks.Verify(service => service.InitializeAsync(), Times.Never); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index ee7a992..f90db6a 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -367,6 +367,33 @@ public async Task GetProcessesByNameAsync_ReturnsMaterializedModels() } } + [Fact] + public void CreateProcessModel_CachesStaticMetadataUntilProcessIsUntracked() + { + var profilesDirectory = CreateTemporaryDirectory(); + var service = CreateService(profilesDirectory); + using var currentProcess = Process.GetCurrentProcess(); + + try + { + var first = service.CreateProcessModel(currentProcess); + var second = service.CreateProcessModel(currentProcess); + var metadata = (System.Collections.IEnumerable)typeof(ProcessService) + .GetField("processMetadata", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(service)!; + + Assert.Equal(first.ExecutablePath, second.ExecutablePath); + Assert.Single(metadata.Cast()); + + service.UntrackProcess(currentProcess.Id); + Assert.Empty(metadata.Cast()); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + [Fact] public void TrackPriorityChange_PreservesOriginalPriority() { diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index c4b178d..b630f5e 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -10,6 +10,19 @@ namespace ThreadPilot.Core.Tests public sealed class SettingsViewModelThemeTests { + [Fact] + public async Task Constructor_DefersPowerPlanLoadingUntilInitializeAsync() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + harness.PowerPlans.Verify(service => service.GetPowerPlansAsync(), Times.Never); + + await viewModel.InitializeAsync(); + + harness.PowerPlans.Verify(service => service.GetPowerPlansAsync(), Times.Once); + } + [Fact] public async Task ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() { diff --git a/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs index e856ca2..97b0051 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTrayStatusUpdaterTests.cs @@ -47,7 +47,7 @@ private sealed class Harness public Harness() { - var activePlan = new PowerPlanModel { Guid = "balanced", Name = "Balanced" }; + var activePlan = new PowerPlanModel { Guid = "balanced", Name = "Balanced", IsActive = true }; this.PowerPlan .Setup(x => x.GetPowerPlansAsync()) .ReturnsAsync(new ObservableCollection { activePlan }); diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 3bab86b..1e56a04 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -18,6 +18,7 @@ public partial class LogViewerViewModel : ObservableObject private readonly IEnhancedLoggingService loggingService; private readonly IApplicationSettingsService settingsService; private readonly ILogger logger; + private bool isActive; [ObservableProperty] private ObservableCollection logEntries = new(); @@ -130,6 +131,12 @@ public LogViewerViewModel( } } + public Task SetActiveAsync(bool active) + { + this.isActive = active; + return active ? this.InitializeAsync() : Task.CompletedTask; + } + public async Task InitializeAsync() { try @@ -350,7 +357,7 @@ private void StartAutoRefresh() private void OnActivityEntryAdded(object? sender, ActivityAuditEntry entry) { - if (!this.ShouldDisplay(entry)) + if (!this.isActive || !this.ShouldDisplay(entry)) { return; } @@ -390,22 +397,18 @@ private static LogEntryDisplayModel ToDisplayModel(ActivityAuditEntry entry) => Details = entry.Details, }; - partial void OnSearchTextChanged(string value) - { - // Trigger refresh when search text changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); - } + partial void OnSearchTextChanged(string value) => this.RefreshIfActive(); - partial void OnSelectedCategoryChanged(string value) - { - // Trigger refresh when category changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); - } + partial void OnSelectedCategoryChanged(string value) => this.RefreshIfActive(); + + partial void OnSelectedLogLevelChanged(LogLevel value) => this.RefreshIfActive(); - partial void OnSelectedLogLevelChanged(LogLevel value) + private void RefreshIfActive() { - // Trigger refresh when log level changes - marshal to UI thread to prevent cross-thread access exceptions - _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + if (this.isActive) + { + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } } private static Task InvokeOnUiAsync(Action action) diff --git a/ViewModels/PowerPlanViewModel.cs b/ViewModels/PowerPlanViewModel.cs index 1a39584..2e832ed 100644 --- a/ViewModels/PowerPlanViewModel.cs +++ b/ViewModels/PowerPlanViewModel.cs @@ -291,17 +291,11 @@ await this.SetOperationFailedAsync( private async Task RefreshPowerPlansCoreAsync(bool reportStatus) { var currentPlans = await this.powerPlanService.GetPowerPlansAsync(); - var currentActive = await this.powerPlanService.GetActivePowerPlan(); var customPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); this.PowerPlans = new ObservableCollection(currentPlans); this.CustomPowerPlans = new ObservableCollection(customPlans); - this.ActivePowerPlan = currentActive; - - foreach (var plan in this.PowerPlans) - { - plan.IsActive = string.Equals(plan.Guid, currentActive?.Guid, StringComparison.OrdinalIgnoreCase); - } + this.ActivePowerPlan = this.PowerPlans.FirstOrDefault(plan => plan.IsActive); if (this.SelectedPowerPlan != null) { diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index e8a9e19..46b4dde 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -21,6 +21,7 @@ public partial class ProcessPowerPlanAssociationViewModel : BaseViewModel private readonly IProcessService processService; private readonly IProcessMonitorManagerService monitorManagerService; private readonly ICoreMaskService coreMaskService; + private bool isInitialized; [ObservableProperty] private ObservableCollection associations = new(); @@ -132,13 +133,16 @@ public ProcessPowerPlanAssociationViewModel( this.associationService.ConfigurationChanged += this.OnConfigurationChanged; this.monitorManagerService.ServiceStatusChanged += this.OnServiceStatusChanged; this.monitorManagerService.ProcessPowerPlanChanged += this.OnProcessPowerPlanChanged; - - // Initialize - _ = this.InitializeAsync(); } public override async Task InitializeAsync() { + if (this.isInitialized) + { + return; + } + + this.isInitialized = true; await this.LoadDataAsync(); this.UpdateServiceStatus(); } @@ -480,8 +484,10 @@ public void ClearSelectedExecutable() private void OnConfigurationChanged(object? sender, ConfigurationChangedEventArgs e) { - // Reload data when configuration changes - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.LoadDataAsync()); + if (this.isInitialized) + { + _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.LoadDataAsync()); + } } private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 3a5c0f0..8b97079 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -116,7 +116,7 @@ private async Task RefreshPowerPlansAsync() try { var plans = await this.powerPlanService.GetPowerPlansAsync(); - var activePlan = await this.powerPlanService.GetActivePowerPlan(); + var activePlan = plans.FirstOrDefault(plan => plan.IsActive); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 901c61e..08dfa8c 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -147,19 +147,15 @@ public SettingsViewModel( // Keep viewmodel in sync with persisted settings this.settingsService.SettingsChanged += this.OnSettingsServiceSettingsChanged; - var dispatcher = System.Windows.Application.Current?.Dispatcher; - if (dispatcher != null) - { - // Ensure we load the latest persisted settings on startup. - _ = dispatcher.InvokeAsync(async () => await this.RefreshSettingsAsync()); - - // Initialize data - marshal to UI thread to prevent cross-thread access exceptions. - _ = dispatcher.InvokeAsync(async () => await this.RefreshPowerPlansAsync()); - } - this.Logger.LogInformation("Settings ViewModel initialized"); } + public override async Task InitializeAsync() + { + await this.RefreshSettingsAsync(); + await this.RefreshPowerPlansAsync(); + } + private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (this.isSyncingFromService)