From 41c2a78f21d86b4eccdfeefecee001ba07a9a596 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 00:02:44 +0200 Subject: [PATCH 1/2] Add selected process summary --- .../SelectedProcessSummaryViewModelTests.cs | 182 ++++++++++ .../ProcessViewModel.Behaviors.partial.cs | 11 + ViewModels/ProcessViewModel.cs | 11 +- ViewModels/SelectedProcessSummaryViewModel.cs | 314 ++++++++++++++++++ Views/ProcessView.xaml | 63 ++++ 5 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs create mode 100644 ViewModels/SelectedProcessSummaryViewModel.cs diff --git a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs new file mode 100644 index 0000000..a7bea01 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs @@ -0,0 +1,182 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using System.Reflection; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SelectedProcessSummaryViewModelTests + { + [Fact] + public async Task UpdateAsync_WithNoSelectedProcess_ClearsSummary() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(null); + + Assert.False(viewModel.HasSelection); + Assert.Equal("No process selected", viewModel.CurrentProcessStatusText); + Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); + Assert.Equal("No saved rule", viewModel.RuleStatusText); + } + + [Fact] + public async Task UpdateAsync_WithSelectedProcess_PopulatesCheapProcessFields() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(CreateProcess("Game.exe", 1234, ProcessPriorityClass.High, 0x3, 512 * 1024 * 1024)); + + Assert.True(viewModel.HasSelection); + Assert.Equal(1234, viewModel.ProcessId); + Assert.Equal("Game.exe", viewModel.ProcessName); + Assert.Equal(@"C:\Games\Game.exe", viewModel.ExecutablePath); + Assert.Equal("Selected process: Game.exe (PID 1234)", viewModel.ProcessTitle); + Assert.Equal("CPU priority: High", viewModel.CpuPriorityText); + Assert.Equal("Memory: 512 MB", viewModel.MemoryUsageText); + Assert.Equal("Affinity: legacy mask 0x3", viewModel.AffinityText); + } + + [Fact] + public async Task UpdateAsync_WhenSelectionChanges_ReplacesSummary() + { + var viewModel = new SelectedProcessSummaryViewModel(); + + await viewModel.UpdateAsync(CreateProcess("First.exe", 1, ProcessPriorityClass.Normal, 0x1, 1)); + await viewModel.UpdateAsync(CreateProcess("Second.exe", 2, ProcessPriorityClass.BelowNormal, 0x2, 2)); + + Assert.Equal(2, viewModel.ProcessId); + Assert.Equal("Second.exe", viewModel.ProcessName); + Assert.Equal("CPU priority: BelowNormal", viewModel.CpuPriorityText); + Assert.Equal("Affinity: legacy mask 0x2", viewModel.AffinityText); + } + + [Fact] + public async Task UpdateAsync_WhenMemoryPriorityReadSucceeds_PopulatesMemoryPriority() + { + var memoryPriority = new Mock(MockBehavior.Strict); + memoryPriority + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ReturnsAsync(ProcessMemoryPriority.BelowNormal); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); + + await viewModel.UpdateAsync(CreateProcess()); + + Assert.Equal(ProcessMemoryPriority.BelowNormal, viewModel.MemoryPriority); + Assert.Equal("Memory priority: BelowNormal", viewModel.MemoryPriorityText); + } + + [Fact] + public async Task UpdateAsync_WhenMemoryPriorityUnavailable_ShowsUnavailableWithoutThrowing() + { + var memoryPriority = new Mock(MockBehavior.Strict); + memoryPriority + .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException("Access denied")); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority.Object); + + await viewModel.UpdateAsync(CreateProcess()); + + Assert.Null(viewModel.MemoryPriority); + Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); + } + + [Fact] + public void SelectedProcessSummary_HasNoPerformanceMonitoringDependency() + { + var type = typeof(SelectedProcessSummaryViewModel); + + var constructorParameters = type + .GetConstructors() + .SelectMany(ctor => ctor.GetParameters()) + .Select(parameter => parameter.ParameterType); + var fieldTypes = type + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(field => field.FieldType); + + Assert.DoesNotContain(typeof(IPerformanceMonitoringService), constructorParameters); + Assert.DoesNotContain(typeof(IPerformanceMonitoringService), fieldTypes); + } + + [Fact] + public void SelectedProcessSummary_DoesNotOwnTimers() + { + var fieldTypes = typeof(SelectedProcessSummaryViewModel) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .Select(field => field.FieldType); + + Assert.DoesNotContain(typeof(System.Timers.Timer), fieldTypes); + Assert.DoesNotContain(typeof(System.Threading.Timer), fieldTypes); + } + + [Fact] + public async Task UpdateAsync_WhenPersistentRuleMatches_ShowsSavedRule() + { + var store = new Mock(MockBehavior.Strict); + store + .Setup(ruleStore => ruleStore.LoadAsync()) + .ReturnsAsync(new[] + { + new PersistentProcessRule + { + Name = "Game rule", + ProcessName = "Game.exe", + IsEnabled = true, + }, + }); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store.Object, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + + await viewModel.UpdateAsync(CreateProcess("Game.exe")); + + Assert.True(viewModel.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Game rule", viewModel.RuleStatusText); + } + + [Fact] + public async Task UpdateAsync_WhenNoPersistentRuleMatches_ShowsNoSavedRule() + { + var store = new Mock(MockBehavior.Strict); + store + .Setup(ruleStore => ruleStore.LoadAsync()) + .ReturnsAsync(new[] + { + new PersistentProcessRule + { + Name = "Other rule", + ProcessName = "Other.exe", + IsEnabled = true, + }, + }); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store.Object, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + + await viewModel.UpdateAsync(CreateProcess("Game.exe")); + + Assert.False(viewModel.HasThreadPilotRule); + Assert.Equal("No saved rule", viewModel.RuleStatusText); + } + + private static ProcessModel CreateProcess( + string name = "Game.exe", + int processId = 42, + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + long affinity = 0xF, + long memoryUsage = 64 * 1024 * 1024) + => new() + { + ProcessId = processId, + Name = name, + ExecutablePath = @"C:\Games\Game.exe", + CpuUsage = 12.5, + MemoryUsage = memoryUsage, + Priority = priority, + ProcessorAffinity = affinity, + Classification = ProcessClassification.ForegroundApp, + }; + } +} diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index b9b0e72..8e5ae91 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -154,6 +154,8 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => partial void OnSelectedProcessChanged(ProcessModel? value) { + this.UpdateSelectedProcessSummary(value); + if (value != null && CpuTopology != null) { this.HasPendingAffinityEdits = false; @@ -174,6 +176,13 @@ partial void OnSelectedProcessChanged(ProcessModel? value) this.systemTrayService.UpdateContextMenu(value?.Name, value != null); } + private void UpdateSelectedProcessSummary(ProcessModel? process) + { + TaskSafety.FireAndForget( + this.SelectedProcessSummary.UpdateAsync(process, this.StatusMessage, this.HasError), + ex => this.Logger.LogWarning(ex, "Failed to update selected process summary")); + } + private async Task HandleSelectedProcessChangedAsync(ProcessModel value) { try @@ -213,6 +222,7 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) $"Selected process: {value.Name} (PID: {value.ProcessId}) - " + $"Priority: {value.Priority}, Affinity: 0x{value.ProcessorAffinity:X}", false); }); + this.UpdateSelectedProcessSummary(value); // Load current power plan association if available await this.LoadProcessPowerPlanAssociation(value); @@ -235,6 +245,7 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) { this.SetStatus($"Warning: Could not access process {value.Name} - it may have terminated or require elevated privileges", false); }); + this.UpdateSelectedProcessSummary(value); } } diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index ba40e8a..b86559f 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -180,7 +180,10 @@ public ProcessViewModel( IAffinityApplyService? affinityApplyService = null, IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, ICpuTopologyProvider? cpuTopologyProvider = null, - IEnhancedLoggingService? enhancedLoggingService = null) + IEnhancedLoggingService? enhancedLoggingService = null, + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + IPersistentProcessRuleMatcher? persistentRuleMatcher = null) : base(logger, enhancedLoggingService) { this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); @@ -202,6 +205,10 @@ public ProcessViewModel( cpuTopologyProvider, new CpuSelectionMigrationService(), NullLogger.Instance); + this.SelectedProcessSummary = new SelectedProcessSummaryViewModel( + memoryPriorityService, + persistentRuleStore, + persistentRuleMatcher); // Subscribe to topology detection events this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; @@ -223,5 +230,7 @@ public ProcessViewModel( this.SetupVirtualizedProcessService(); // Note: InitializeAsync() will be called explicitly by MainWindow loading overlay } + + public SelectedProcessSummaryViewModel SelectedProcessSummary { get; } } } diff --git a/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs new file mode 100644 index 0000000..196b077 --- /dev/null +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -0,0 +1,314 @@ +/* + * ThreadPilot - lightweight selected process summary view model. + */ +namespace ThreadPilot.ViewModels +{ + using System.Diagnostics; + using CommunityToolkit.Mvvm.ComponentModel; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class SelectedProcessSummaryViewModel : ObservableObject + { + private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly IPersistentProcessRuleStore? persistentRuleStore; + private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; + private bool hasSelection; + private int processId; + private string processName = string.Empty; + private string executablePath = string.Empty; + private double cpuUsage; + private long memoryUsage; + private ProcessPriorityClass cpuPriority; + private long processorAffinity; + private ProcessMemoryPriority? memoryPriority; + private string processTitle = "No process selected"; + private string currentProcessStatusText = "No process selected"; + private string cpuUsageText = "CPU: unavailable"; + private string memoryUsageText = "Memory: unavailable"; + private string cpuPriorityText = "CPU priority: unavailable"; + private string memoryPriorityText = "Memory priority unavailable"; + private string affinityText = "Affinity: unavailable"; + private string ruleStatusText = "No saved rule"; + private string lastOperationMessage = "No recent ThreadPilot action"; + private string lastOperationSeverity = "Information"; + private bool isProtectedOrAccessDenied; + private bool hasThreadPilotRule; + + public SelectedProcessSummaryViewModel( + IProcessMemoryPriorityService? memoryPriorityService = null, + IPersistentProcessRuleStore? persistentRuleStore = null, + IPersistentProcessRuleMatcher? persistentRuleMatcher = null) + { + this.memoryPriorityService = memoryPriorityService; + this.persistentRuleStore = persistentRuleStore; + this.persistentRuleMatcher = persistentRuleMatcher; + } + + public bool HasSelection + { + get => this.hasSelection; + private set => this.SetProperty(ref this.hasSelection, value); + } + + public int ProcessId + { + get => this.processId; + private set => this.SetProperty(ref this.processId, value); + } + + public string ProcessName + { + get => this.processName; + private set => this.SetProperty(ref this.processName, value); + } + + public string ExecutablePath + { + get => this.executablePath; + private set => this.SetProperty(ref this.executablePath, value); + } + + public double CpuUsage + { + get => this.cpuUsage; + private set => this.SetProperty(ref this.cpuUsage, value); + } + + public long MemoryUsage + { + get => this.memoryUsage; + private set => this.SetProperty(ref this.memoryUsage, value); + } + + public ProcessPriorityClass CpuPriority + { + get => this.cpuPriority; + private set => this.SetProperty(ref this.cpuPriority, value); + } + + public long ProcessorAffinity + { + get => this.processorAffinity; + private set => this.SetProperty(ref this.processorAffinity, value); + } + + public ProcessMemoryPriority? MemoryPriority + { + get => this.memoryPriority; + private set => this.SetProperty(ref this.memoryPriority, value); + } + + public string ProcessTitle + { + get => this.processTitle; + private set => this.SetProperty(ref this.processTitle, value); + } + + public string CurrentProcessStatusText + { + get => this.currentProcessStatusText; + private set => this.SetProperty(ref this.currentProcessStatusText, value); + } + + public string CpuUsageText + { + get => this.cpuUsageText; + private set => this.SetProperty(ref this.cpuUsageText, value); + } + + public string MemoryUsageText + { + get => this.memoryUsageText; + private set => this.SetProperty(ref this.memoryUsageText, value); + } + + public string CpuPriorityText + { + get => this.cpuPriorityText; + private set => this.SetProperty(ref this.cpuPriorityText, value); + } + + public string MemoryPriorityText + { + get => this.memoryPriorityText; + private set => this.SetProperty(ref this.memoryPriorityText, value); + } + + public string AffinityText + { + get => this.affinityText; + private set => this.SetProperty(ref this.affinityText, value); + } + + public string RuleStatusText + { + get => this.ruleStatusText; + private set => this.SetProperty(ref this.ruleStatusText, value); + } + + public string LastOperationMessage + { + get => this.lastOperationMessage; + private set => this.SetProperty(ref this.lastOperationMessage, value); + } + + public string LastOperationSeverity + { + get => this.lastOperationSeverity; + private set => this.SetProperty(ref this.lastOperationSeverity, value); + } + + public bool IsProtectedOrAccessDenied + { + get => this.isProtectedOrAccessDenied; + private set => this.SetProperty(ref this.isProtectedOrAccessDenied, value); + } + + public bool HasThreadPilotRule + { + get => this.hasThreadPilotRule; + private set => this.SetProperty(ref this.hasThreadPilotRule, value); + } + + public async Task UpdateAsync( + ProcessModel? process, + string? lastOperationMessage = null, + bool lastOperationIsError = false) + { + if (process == null) + { + this.Clear(lastOperationMessage, lastOperationIsError); + return; + } + + this.HasSelection = true; + this.ProcessId = process.ProcessId; + this.ProcessName = process.Name ?? string.Empty; + this.ExecutablePath = process.ExecutablePath ?? string.Empty; + this.CpuUsage = process.CpuUsage; + this.MemoryUsage = process.MemoryUsage; + this.CpuPriority = process.Priority; + this.ProcessorAffinity = process.ProcessorAffinity; + this.IsProtectedOrAccessDenied = process.Classification == ProcessClassification.ProtectedOrAccessDenied; + this.ProcessTitle = $"Selected process: {this.ProcessName} (PID {this.ProcessId})"; + this.CurrentProcessStatusText = this.IsProtectedOrAccessDenied + ? "Current process status: protected or access denied" + : "Current process status: selected"; + this.CpuUsageText = $"CPU: {this.CpuUsage:N1}%"; + this.MemoryUsageText = $"Memory: {FormatMemory(this.MemoryUsage)}"; + this.CpuPriorityText = $"CPU priority: {this.CpuPriority}"; + this.AffinityText = $"Affinity: legacy mask 0x{this.ProcessorAffinity:X}"; + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + + await this.UpdateMemoryPriorityAsync(process); + await this.UpdateRuleStatusAsync(process); + } + + private static string FormatMemory(long bytes) + { + if (bytes <= 0) + { + return "0 MB"; + } + + var megabytes = bytes / 1024d / 1024d; + return $"{megabytes:N0} MB"; + } + + private void Clear(string? lastOperationMessage, bool lastOperationIsError) + { + this.HasSelection = false; + this.ProcessId = 0; + this.ProcessName = string.Empty; + this.ExecutablePath = string.Empty; + this.CpuUsage = 0; + this.MemoryUsage = 0; + this.CpuPriority = default; + this.ProcessorAffinity = 0; + this.MemoryPriority = null; + this.IsProtectedOrAccessDenied = false; + this.HasThreadPilotRule = false; + this.ProcessTitle = "No process selected"; + this.CurrentProcessStatusText = "No process selected"; + this.CpuUsageText = "CPU: unavailable"; + this.MemoryUsageText = "Memory: unavailable"; + this.CpuPriorityText = "CPU priority: unavailable"; + this.MemoryPriorityText = "Memory priority unavailable"; + this.AffinityText = "Affinity: unavailable"; + this.RuleStatusText = "No saved rule"; + this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); + } + + private async Task UpdateMemoryPriorityAsync(ProcessModel process) + { + this.MemoryPriority = null; + this.MemoryPriorityText = "Memory priority unavailable"; + + if (this.memoryPriorityService == null) + { + return; + } + + try + { + var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + if (priority == null) + { + return; + } + + this.MemoryPriority = priority.Value; + this.MemoryPriorityText = $"Memory priority: {priority.Value}"; + } + catch (Exception) + { + this.MemoryPriority = null; + this.MemoryPriorityText = "Memory priority unavailable"; + } + } + + private async Task UpdateRuleStatusAsync(ProcessModel process) + { + this.HasThreadPilotRule = false; + this.RuleStatusText = "No saved rule"; + + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return; + } + + try + { + var rules = await this.persistentRuleStore.LoadAsync(); + var matchingRule = rules.FirstOrDefault(rule => this.persistentRuleMatcher.IsMatch(rule, process)); + if (matchingRule == null) + { + return; + } + + this.HasThreadPilotRule = true; + var ruleName = string.IsNullOrWhiteSpace(matchingRule.Name) ? "saved rule" : matchingRule.Name.Trim(); + this.RuleStatusText = $"Saved rule exists: {ruleName}"; + } + catch (Exception) + { + this.HasThreadPilotRule = false; + this.RuleStatusText = "No saved rule"; + } + } + + private void UpdateLastOperation(string? message, bool isError) + { + if (string.IsNullOrWhiteSpace(message)) + { + this.LastOperationMessage = "No recent ThreadPilot action"; + this.LastOperationSeverity = "Information"; + return; + } + + this.LastOperationMessage = message.Trim(); + this.LastOperationSeverity = isError ? "Error" : "Information"; + } + } +} diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 933ab17..543bd9b 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -47,6 +47,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Date: Fri, 22 May 2026 00:14:57 +0200 Subject: [PATCH 2/2] Prevent stale selected process summary updates --- .../SelectedProcessSummaryViewModelTests.cs | 166 ++++++++++++++++++ .../ProcessViewModel.Behaviors.partial.cs | 11 +- ViewModels/SelectedProcessSummaryViewModel.cs | 47 ++++- 3 files changed, 216 insertions(+), 8 deletions(-) diff --git a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs index a7bea01..88d1203 100644 --- a/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SelectedProcessSummaryViewModelTests.cs @@ -83,6 +83,72 @@ public async Task UpdateAsync_WhenMemoryPriorityUnavailable_ShowsUnavailableWith Assert.Equal("Memory priority unavailable", viewModel.MemoryPriorityText); } + [Fact] + public async Task UpdateAsync_WhenSelectionChangesBeforeSlowMemoryPriorityCompletes_KeepsLatestSelection() + { + var memoryPriority = new ControlledMemoryPriorityService(); + var viewModel = new SelectedProcessSummaryViewModel(memoryPriority); + var oldProcess = CreateProcess("Old.exe", 100, ProcessPriorityClass.Normal, 0x1, 10); + var latestProcess = CreateProcess("Latest.exe", 200, ProcessPriorityClass.High, 0x2, 20); + + var oldUpdate = viewModel.UpdateAsync(oldProcess); + await memoryPriority.WaitForReadAsync(oldProcess.ProcessId); + + memoryPriority.SetImmediatePriority(latestProcess.ProcessId, ProcessMemoryPriority.Normal); + await viewModel.UpdateAsync(latestProcess); + + memoryPriority.CompleteRead(oldProcess.ProcessId, ProcessMemoryPriority.VeryLow); + await oldUpdate; + + Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); + Assert.Equal(latestProcess.Name, viewModel.ProcessName); + Assert.Equal(ProcessMemoryPriority.Normal, viewModel.MemoryPriority); + Assert.Equal("Memory priority: Normal", viewModel.MemoryPriorityText); + } + + [Fact] + public async Task UpdateAsync_WhenSlowRuleLookupCompletesAfterSelectionChange_KeepsLatestRuleStatus() + { + var store = new ControlledPersistentProcessRuleStore(); + var viewModel = new SelectedProcessSummaryViewModel( + persistentRuleStore: store, + persistentRuleMatcher: new PersistentProcessRuleMatcher()); + var oldProcess = CreateProcess("Old.exe", 100); + var latestProcess = CreateProcess("Latest.exe", 200); + + var oldUpdate = viewModel.UpdateAsync(oldProcess); + await store.WaitForLoadAsync(1); + + store.EnqueueImmediateRules(new[] + { + new PersistentProcessRule + { + Name = "Latest rule", + ProcessName = latestProcess.Name, + IsEnabled = true, + }, + }); + await viewModel.UpdateAsync(latestProcess); + + store.CompleteLoad( + 1, + new[] + { + new PersistentProcessRule + { + Name = "Old rule", + ProcessName = oldProcess.Name, + IsEnabled = true, + }, + }); + await oldUpdate; + + Assert.Equal(latestProcess.ProcessId, viewModel.ProcessId); + Assert.Equal(latestProcess.Name, viewModel.ProcessName); + Assert.True(viewModel.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Latest rule", viewModel.RuleStatusText); + } + [Fact] public void SelectedProcessSummary_HasNoPerformanceMonitoringDependency() { @@ -178,5 +244,105 @@ private static ProcessModel CreateProcess( ProcessorAffinity = affinity, Classification = ProcessClassification.ForegroundApp, }; + + private sealed class ControlledMemoryPriorityService : IProcessMemoryPriorityService + { + private readonly Dictionary> pendingReads = new(); + private readonly Dictionary readSignals = new(); + private readonly Dictionary immediatePriorities = new(); + + public Task GetMemoryPriorityAsync(ProcessModel process) + { + if (this.immediatePriorities.TryGetValue(process.ProcessId, out var priority)) + { + return Task.FromResult(priority); + } + + var pending = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var signal = this.GetOrCreateReadSignal(process.ProcessId); + this.pendingReads[process.ProcessId] = pending; + signal.TrySetResult(); + return pending.Task; + } + + public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) + => throw new NotSupportedException(); + + public void SetImmediatePriority(int processId, ProcessMemoryPriority? priority) + { + this.immediatePriorities[processId] = priority; + } + + public Task WaitForReadAsync(int processId) => this.GetOrCreateReadSignal(processId).Task; + + public void CompleteRead(int processId, ProcessMemoryPriority? priority) + { + this.pendingReads[processId].SetResult(priority); + } + + private TaskCompletionSource GetOrCreateReadSignal(int processId) + { + if (!this.readSignals.TryGetValue(processId, out var signal)) + { + signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.readSignals[processId] = signal; + } + + return signal; + } + } + + private sealed class ControlledPersistentProcessRuleStore : IPersistentProcessRuleStore + { + private readonly Dictionary>> pendingLoads = new(); + private readonly Dictionary loadSignals = new(); + private readonly Queue> immediateRules = new(); + private int loadCount; + + public Task> LoadAsync() + { + this.loadCount++; + var loadNumber = this.loadCount; + + if (this.immediateRules.Count > 0) + { + this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); + return Task.FromResult(this.immediateRules.Dequeue()); + } + + var pending = new TaskCompletionSource>( + TaskCreationOptions.RunContinuationsAsynchronously); + this.pendingLoads[loadNumber] = pending; + this.GetOrCreateLoadSignal(loadNumber).TrySetResult(); + return pending.Task; + } + + public Task SaveAsync(IReadOnlyList rules) + => throw new NotSupportedException(); + + public void EnqueueImmediateRules(IReadOnlyList rules) + { + this.immediateRules.Enqueue(rules); + } + + public Task WaitForLoadAsync(int loadNumber) => this.GetOrCreateLoadSignal(loadNumber).Task; + + public void CompleteLoad(int loadNumber, IReadOnlyList rules) + { + this.pendingLoads[loadNumber].SetResult(rules); + } + + private TaskCompletionSource GetOrCreateLoadSignal(int loadNumber) + { + if (!this.loadSignals.TryGetValue(loadNumber, out var signal)) + { + signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.loadSignals[loadNumber] = signal; + } + + return signal; + } + } } } diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 8e5ae91..6f0bd72 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -222,7 +222,11 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) $"Selected process: {value.Name} (PID: {value.ProcessId}) - " + $"Priority: {value.Priority}, Affinity: 0x{value.ProcessorAffinity:X}", false); }); - this.UpdateSelectedProcessSummary(value); + if (ReferenceEquals(this.SelectedProcess, value)) + { + // Keep this second update for refreshed process fields and the latest operation message. + this.UpdateSelectedProcessSummary(value); + } // Load current power plan association if available await this.LoadProcessPowerPlanAssociation(value); @@ -245,7 +249,10 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) { this.SetStatus($"Warning: Could not access process {value.Name} - it may have terminated or require elevated privileges", false); }); - this.UpdateSelectedProcessSummary(value); + if (ReferenceEquals(this.SelectedProcess, value)) + { + this.UpdateSelectedProcessSummary(value); + } } } diff --git a/ViewModels/SelectedProcessSummaryViewModel.cs b/ViewModels/SelectedProcessSummaryViewModel.cs index 196b077..18d32d9 100644 --- a/ViewModels/SelectedProcessSummaryViewModel.cs +++ b/ViewModels/SelectedProcessSummaryViewModel.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.ViewModels { using System.Diagnostics; + using System.Threading; using CommunityToolkit.Mvvm.ComponentModel; using ThreadPilot.Models; using ThreadPilot.Services; @@ -34,6 +35,7 @@ public sealed class SelectedProcessSummaryViewModel : ObservableObject private string lastOperationSeverity = "Information"; private bool isProtectedOrAccessDenied; private bool hasThreadPilotRule; + private int updateVersion; public SelectedProcessSummaryViewModel( IProcessMemoryPriorityService? memoryPriorityService = null, @@ -176,9 +178,10 @@ public async Task UpdateAsync( string? lastOperationMessage = null, bool lastOperationIsError = false) { + var version = Interlocked.Increment(ref this.updateVersion); if (process == null) { - this.Clear(lastOperationMessage, lastOperationIsError); + this.Clear(version, lastOperationMessage, lastOperationIsError); return; } @@ -201,8 +204,13 @@ public async Task UpdateAsync( this.AffinityText = $"Affinity: legacy mask 0x{this.ProcessorAffinity:X}"; this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); - await this.UpdateMemoryPriorityAsync(process); - await this.UpdateRuleStatusAsync(process); + await this.UpdateMemoryPriorityAsync(process, version); + if (!this.IsCurrentVersion(version)) + { + return; + } + + await this.UpdateRuleStatusAsync(process, version); } private static string FormatMemory(long bytes) @@ -216,8 +224,13 @@ private static string FormatMemory(long bytes) return $"{megabytes:N0} MB"; } - private void Clear(string? lastOperationMessage, bool lastOperationIsError) + private void Clear(int version, string? lastOperationMessage, bool lastOperationIsError) { + if (!this.IsCurrentVersion(version)) + { + return; + } + this.HasSelection = false; this.ProcessId = 0; this.ProcessName = string.Empty; @@ -240,7 +253,7 @@ private void Clear(string? lastOperationMessage, bool lastOperationIsError) this.UpdateLastOperation(lastOperationMessage, lastOperationIsError); } - private async Task UpdateMemoryPriorityAsync(ProcessModel process) + private async Task UpdateMemoryPriorityAsync(ProcessModel process, int version) { this.MemoryPriority = null; this.MemoryPriorityText = "Memory priority unavailable"; @@ -253,6 +266,11 @@ private async Task UpdateMemoryPriorityAsync(ProcessModel process) try { var priority = await this.memoryPriorityService.GetMemoryPriorityAsync(process); + if (!this.IsCurrentVersion(version)) + { + return; + } + if (priority == null) { return; @@ -263,12 +281,17 @@ private async Task UpdateMemoryPriorityAsync(ProcessModel process) } catch (Exception) { + if (!this.IsCurrentVersion(version)) + { + return; + } + this.MemoryPriority = null; this.MemoryPriorityText = "Memory priority unavailable"; } } - private async Task UpdateRuleStatusAsync(ProcessModel process) + private async Task UpdateRuleStatusAsync(ProcessModel process, int version) { this.HasThreadPilotRule = false; this.RuleStatusText = "No saved rule"; @@ -281,6 +304,11 @@ private async Task UpdateRuleStatusAsync(ProcessModel process) try { var rules = await this.persistentRuleStore.LoadAsync(); + if (!this.IsCurrentVersion(version)) + { + return; + } + var matchingRule = rules.FirstOrDefault(rule => this.persistentRuleMatcher.IsMatch(rule, process)); if (matchingRule == null) { @@ -293,11 +321,18 @@ private async Task UpdateRuleStatusAsync(ProcessModel process) } catch (Exception) { + if (!this.IsCurrentVersion(version)) + { + return; + } + this.HasThreadPilotRule = false; this.RuleStatusText = "No saved rule"; } } + private bool IsCurrentVersion(int version) => Volatile.Read(ref this.updateVersion) == version; + private void UpdateLastOperation(string? message, bool isError) { if (string.IsNullOrWhiteSpace(message))