From e9d34c38f57e832090535e4bebb28e51681eb58d Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 01:07:15 +0200 Subject: [PATCH 1/2] Add explicit process rule save flow --- Services/ProcessRuleCreationService.cs | 455 ++++++++++++++++++ Services/ServiceConfiguration.cs | 1 + .../ProcessRuleCreationServiceTests.cs | 287 +++++++++++ .../ProcessViewModelContextMenuTests.cs | 187 +++++++ .../ProcessViewModel.Behaviors.partial.cs | 109 +++++ ViewModels/ProcessViewModel.cs | 9 + Views/ProcessView.xaml | 9 + 7 files changed, 1057 insertions(+) create mode 100644 Services/ProcessRuleCreationService.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs new file mode 100644 index 0000000..8c6f607 --- /dev/null +++ b/Services/ProcessRuleCreationService.cs @@ -0,0 +1,455 @@ +/* + * ThreadPilot - persistent process rule creation from explicit Process tab actions. + */ +namespace ThreadPilot.Services +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IProcessRuleCreationService + { + Task SaveRuleAsync( + ProcessModel process, + ProcessRuleCreationPayload payload, + CancellationToken cancellationToken = default); + + Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CancellationToken cancellationToken = default); + } + + public sealed record ProcessRuleCreationPayload + { + public CpuSelection? CpuSelection { get; init; } + + public long? LegacyAffinityMask { get; init; } + + public ProcessPriorityClass? Priority { get; init; } + + public ProcessMemoryPriority? MemoryPriority { get; init; } + } + + public sealed record ProcessRuleCreationResult + { + public bool Success { get; init; } + + public bool Created { get; init; } + + public bool Updated { get; init; } + + public PersistentProcessRule? Rule { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string ErrorCode { get; init; } = string.Empty; + + public static ProcessRuleCreationResult Failed(string errorCode, string userMessage) => + new() + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + }; + } + + public sealed class ProcessRuleCreationService : IProcessRuleCreationService + { + public const string NoCurrentSettingsMessage = + "There are no current settings to save as a rule."; + + public const string UnsafeAffinityMessage = + "The current affinity selection cannot be saved safely on this CPU topology."; + + private const string RuleDescription = "Created from Process tab action."; + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly ICpuTopologyProvider? topologyProvider; + private readonly CpuSelectionMigrationService migrationService; + private readonly ILogger logger; + + public ProcessRuleCreationService( + IPersistentProcessRuleStore ruleStore, + ICpuTopologyProvider? topologyProvider, + CpuSelectionMigrationService migrationService, + ILogger logger) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.topologyProvider = topologyProvider; + this.migrationService = migrationService ?? throw new ArgumentNullException(nameof(migrationService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + var payload = new ProcessRuleCreationPayload + { + Priority = ProcessPriorityGuardrails.IsBlocked(process.Priority) + ? null + : process.Priority, + MemoryPriority = currentMemoryPriority, + }; + + var affinityPayload = currentCoreSelection == null + ? await this.BuildAffinityPayloadFromLegacyMaskAsync( + process.ProcessorAffinity, + "Saved current Process tab affinity", + cancellationToken).ConfigureAwait(false) + : await this.BuildAffinityPayloadFromCoreSelectionAsync( + currentCoreSelection, + "Saved current Process tab affinity", + cancellationToken).ConfigureAwait(false); + + if (!affinityPayload.Success) + { + return affinityPayload; + } + + if (affinityPayload.Payload != null) + { + payload = payload with + { + CpuSelection = affinityPayload.Payload.CpuSelection, + LegacyAffinityMask = affinityPayload.Payload.LegacyAffinityMask, + }; + } + + return await this.SaveRuleAsync(process, payload, cancellationToken).ConfigureAwait(false); + } + + public async Task SaveRuleAsync( + ProcessModel process, + ProcessRuleCreationPayload payload, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(payload); + + var sanitizedPayload = SanitizePayload(payload); + if (!sanitizedPayload.Success) + { + return sanitizedPayload; + } + + payload = sanitizedPayload.Payload!; + if (!HasActionablePayload(payload)) + { + return ProcessRuleCreationResult.Failed("NoActionableRulePayload", NoCurrentSettingsMessage); + } + + var rules = (await this.ruleStore.LoadAsync().ConfigureAwait(false)).ToList(); + var existingIndex = FindExistingRuleIndex(rules, process); + var created = existingIndex < 0; + var now = DateTime.UtcNow; + var processName = string.IsNullOrWhiteSpace(process.Name) + ? "process" + : process.Name.Trim(); + var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) + ? null + : process.ExecutablePath.Trim(); + var existing = created ? null : rules[existingIndex]; + var rule = new PersistentProcessRule + { + Id = existing?.Id ?? Guid.NewGuid().ToString("N"), + Name = $"{processName} rule", + IsEnabled = true, + ProcessName = processName, + ExecutablePath = executablePath, + CpuSelection = payload.CpuSelection, + LegacyAffinityMask = HasSelectionPayload(payload.CpuSelection) ? null : payload.LegacyAffinityMask, + Priority = payload.Priority, + MemoryPriority = payload.MemoryPriority, + ApplyAffinityOnStart = HasSelectionPayload(payload.CpuSelection) || payload.LegacyAffinityMask.HasValue, + ApplyPriorityOnStart = payload.Priority.HasValue, + ApplyMemoryPriorityOnStart = payload.MemoryPriority.HasValue, + CreatedAt = existing?.CreatedAt ?? now, + UpdatedAt = now, + Description = RuleDescription, + }; + + if (created) + { + rules.Add(rule); + } + else + { + rules[existingIndex] = rule; + } + + cancellationToken.ThrowIfCancellationRequested(); + await this.ruleStore.SaveAsync(rules).ConfigureAwait(false); + + return new ProcessRuleCreationResult + { + Success = true, + Created = created, + Updated = !created, + Rule = rule, + UserMessage = created + ? $"Saved rule for {processName}." + : $"Updated saved rule for {processName}.", + }; + } + + private static PayloadBuildResult BuildLegacyAffinityPayload(IReadOnlyList currentCoreSelection) + { + if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) + { + return PayloadBuildResult.Empty(); + } + + if (currentCoreSelection.Count > 64) + { + return PayloadBuildResult.Failed("UnsafeLegacyAffinity", UnsafeAffinityMessage); + } + + long legacyMask = 0; + for (var bit = 0; bit < currentCoreSelection.Count; bit++) + { + if (currentCoreSelection[bit]) + { + legacyMask |= 1L << bit; + } + } + + return legacyMask == 0 + ? PayloadBuildResult.Empty() + : PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); + } + + private static PayloadSanitizationResult SanitizePayload(ProcessRuleCreationPayload payload) + { + if (payload.Priority.HasValue && ProcessPriorityGuardrails.IsBlocked(payload.Priority.Value)) + { + return PayloadSanitizationResult.Failed( + "RealtimePriorityBlocked", + ProcessOperationUserMessages.RealtimePriorityBlocked); + } + + var hasCpuSelection = HasSelectionPayload(payload.CpuSelection); + var legacyMask = hasCpuSelection ? null : payload.LegacyAffinityMask; + if (legacyMask.HasValue && legacyMask.Value == 0) + { + legacyMask = null; + } + + return PayloadSanitizationResult.Succeeded(payload with + { + CpuSelection = hasCpuSelection ? payload.CpuSelection : null, + LegacyAffinityMask = legacyMask, + }); + } + + private static bool HasActionablePayload(ProcessRuleCreationPayload payload) => + HasSelectionPayload(payload.CpuSelection) || + payload.LegacyAffinityMask.HasValue || + payload.Priority.HasValue || + payload.MemoryPriority.HasValue; + + private static bool HasSelectionPayload(CpuSelection? selection) => + selection != null && + (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); + + private static int FindExistingRuleIndex(IReadOnlyList rules, ProcessModel process) + { + var executablePath = string.IsNullOrWhiteSpace(process.ExecutablePath) + ? null + : process.ExecutablePath.Trim(); + if (!string.IsNullOrWhiteSpace(executablePath)) + { + for (var index = 0; index < rules.Count; index++) + { + if (string.Equals( + rules[index].ExecutablePath, + executablePath, + StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + var pathlessNameMatch = FindProcessNameMatchIndex(rules, process, requirePathUnavailable: true); + return pathlessNameMatch; + } + + return FindProcessNameMatchIndex(rules, process, requirePathUnavailable: false); + } + + private static int FindProcessNameMatchIndex( + IReadOnlyList rules, + ProcessModel process, + bool requirePathUnavailable) + { + var processName = string.IsNullOrWhiteSpace(process.Name) + ? null + : process.Name.Trim(); + if (string.IsNullOrWhiteSpace(processName)) + { + return -1; + } + + for (var index = 0; index < rules.Count; index++) + { + if (requirePathUnavailable && !string.IsNullOrWhiteSpace(rules[index].ExecutablePath)) + { + continue; + } + + if (string.Equals(rules[index].ProcessName, processName, StringComparison.OrdinalIgnoreCase)) + { + return index; + } + } + + return -1; + } + + private async Task BuildAffinityPayloadFromCoreSelectionAsync( + IReadOnlyList currentCoreSelection, + string selectionReason, + CancellationToken cancellationToken) + { + if (currentCoreSelection.Count == 0 || !currentCoreSelection.Any(selected => selected)) + { + return PayloadBuildResult.Empty(); + } + + var selection = await this.TryMigrateCoreSelectionAsync( + currentCoreSelection, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (selection != null) + { + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); + } + + return BuildLegacyAffinityPayload(currentCoreSelection); + } + + private async Task BuildAffinityPayloadFromLegacyMaskAsync( + long legacyMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (legacyMask == 0) + { + return PayloadBuildResult.Empty(); + } + + var selection = await this.TryMigrateLegacyMaskAsync( + legacyMask, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (selection != null) + { + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { CpuSelection = selection }); + } + + return PayloadBuildResult.Succeeded(new ProcessRuleCreationPayload { LegacyAffinityMask = legacyMask }); + } + + private async Task TryMigrateCoreSelectionAsync( + IReadOnlyList currentCoreSelection, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.topologyProvider == null) + { + return null; + } + + try + { + var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.migrationService.MigrateFromLegacyCoreMask(currentCoreSelection, topology); + return WithSelectionReason(migrated.Selection, selectionReason); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate current core selection to CpuSelection for saved rule"); + return null; + } + } + + private async Task TryMigrateLegacyMaskAsync( + long legacyMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.topologyProvider == null) + { + return null; + } + + try + { + var topology = await this.topologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.migrationService.MigrateFromLegacyAffinityMask(legacyMask, topology); + return WithSelectionReason(migrated.Selection, selectionReason); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate current legacy affinity mask to CpuSelection for saved rule"); + return null; + } + } + + private static CpuSelection? WithSelectionReason(CpuSelection? selection, string selectionReason) + { + if (!HasSelectionPayload(selection)) + { + return null; + } + + return selection! with + { + Metadata = selection.Metadata with + { + SelectionReason = selectionReason, + }, + }; + } + + private sealed record PayloadBuildResult( + bool Success, + ProcessRuleCreationPayload? Payload, + string ErrorCode, + string UserMessage) + { + public static PayloadBuildResult Empty() => new(true, null, string.Empty, string.Empty); + + public static PayloadBuildResult Succeeded(ProcessRuleCreationPayload payload) => + new(true, payload, string.Empty, string.Empty); + + public static PayloadBuildResult Failed(string errorCode, string userMessage) => + new(false, null, errorCode, userMessage); + + public static implicit operator ProcessRuleCreationResult(PayloadBuildResult result) => + ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); + } + + private sealed record PayloadSanitizationResult( + bool Success, + ProcessRuleCreationPayload? Payload, + string ErrorCode, + string UserMessage) + { + public static PayloadSanitizationResult Succeeded(ProcessRuleCreationPayload payload) => + new(true, payload, string.Empty, string.Empty); + + public static PayloadSanitizationResult Failed(string errorCode, string userMessage) => + new(false, null, errorCode, userMessage); + + public static implicit operator ProcessRuleCreationResult(PayloadSanitizationResult result) => + ProcessRuleCreationResult.Failed(result.ErrorCode, result.UserMessage); + } + } +} diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 5f84267..ea72718 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -112,6 +112,7 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs new file mode 100644 index 0000000..79de6e0 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -0,0 +1,287 @@ +/* + * ThreadPilot - persistent process rule creation tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessRuleCreationServiceTests + { + [Fact] + public async Task SaveRuleAsync_UsesExecutablePathWhenAvailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + var process = CreateProcess(path: @"C:\Games\Game.exe"); + + var result = await service.SaveRuleAsync( + process, + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); + Assert.Equal("Game.exe", rule.ProcessName); + Assert.True(rule.IsEnabled); + Assert.Equal("Game.exe rule", rule.Name); + Assert.Equal("Created from Process tab action.", rule.Description); + Assert.True(result.Created); + Assert.False(result.Updated); + Assert.Equal("Saved rule for Game.exe.", result.UserMessage); + } + + [Fact] + public async Task SaveRuleAsync_FallsBackToProcessNameWhenPathUnavailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + await service.SaveRuleAsync( + CreateProcess(path: string.Empty), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.Normal }); + + var rule = Assert.Single(store.SavedRules); + Assert.Null(rule.ExecutablePath); + Assert.Equal("Game.exe", rule.ProcessName); + } + + [Fact] + public async Task SaveRuleAsync_UpdatesExistingPathMatchWithoutDuplicating() + { + var createdAt = DateTime.UtcNow.AddDays(-2); + var existing = new PersistentProcessRule + { + Id = "existing-rule", + Name = "Old", + IsEnabled = true, + ProcessName = "Game.exe", + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + ApplyPriorityOnStart = true, + CreatedAt = createdAt, + UpdatedAt = createdAt, + }; + var store = new CapturingRuleStore([existing]); + var service = CreateService(store); + + var result = await service.SaveRuleAsync( + CreateProcess(path: @"C:\Games\Game.exe"), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.High }); + + var rule = Assert.Single(store.SavedRules); + Assert.True(result.Updated); + Assert.False(result.Created); + Assert.Equal("Updated saved rule for Game.exe.", result.UserMessage); + Assert.Equal("existing-rule", rule.Id); + Assert.Equal(createdAt, rule.CreatedAt); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.True(rule.UpdatedAt > createdAt); + } + + [Fact] + public async Task SaveRuleAsync_UpdatesExistingPathlessNameMatchWhenNewPathIsAvailable() + { + var existing = new PersistentProcessRule + { + Id = "pathless-rule", + Name = "Game.exe rule", + IsEnabled = true, + ProcessName = "Game.exe", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = DateTime.UtcNow.AddDays(-1), + }; + var store = new CapturingRuleStore([existing]); + var service = CreateService(store); + + await service.SaveRuleAsync( + CreateProcess(path: @"C:\Games\Game.exe"), + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); + + var rule = Assert.Single(store.SavedRules); + Assert.Equal("pathless-rule", rule.Id); + Assert.Equal(@"C:\Games\Game.exe", rule.ExecutablePath); + Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); + } + + [Fact] + public async Task SaveRuleAsync_SavesCpuSelectionWhenProvided() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + var selection = CreateCpuSelection(); + + await service.SaveRuleAsync( + CreateProcess(), + new ProcessRuleCreationPayload { CpuSelection = selection }); + + var rule = Assert.Single(store.SavedRules); + Assert.Same(selection, rule.CpuSelection); + Assert.Null(rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_PrefersCpuSelectionWhenTopologyIsAvailable() + { + var store = new CapturingRuleStore(); + var topologyProvider = new FakeTopologyProvider(CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + ])); + var service = CreateService(store, topologyProvider); + + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: [true, false], + currentMemoryPriority: null); + + var rule = Assert.Single(store.SavedRules); + Assert.NotNull(rule.CpuSelection); + Assert.Null(rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Equal(0, rule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_SavesLegacyMaskWhenSelectionIsSafelyRepresentable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store, topologyProvider: null); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), + currentCoreSelection: [true, true, false], + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x3, rule.LegacyAffinityMask); + Assert.Null(rule.CpuSelection); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_BlocksUnsafeLegacyAffinity() + { + var store = new CapturingRuleStore(); + var service = CreateService(store, topologyProvider: null); + var unsafeSelection = Enumerable.Repeat(true, 65).ToArray(); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + unsafeSelection, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal( + "The current affinity selection cannot be saved safely on this CPU topology.", + result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_BlocksRealtimePriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_SavesMemoryPriorityWhenAvailable() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: ProcessMemoryPriority.BelowNormal); + + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessMemoryPriority.BelowNormal, rule.MemoryPriority); + Assert.True(rule.ApplyMemoryPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_ReturnsControlledFailureWhenNoActionablePayloadExists() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: [], + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + private static ProcessRuleCreationService CreateService( + CapturingRuleStore store, + ICpuTopologyProvider? topologyProvider = null) => + new( + store, + topologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private static CpuSelection CreateCpuSelection() => + new() + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + + private static ProcessModel CreateProcess( + string name = "Game.exe", + string path = @"C:\Games\Game.exe", + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + long affinity = 0xF) => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = path, + Priority = priority, + ProcessorAffinity = affinity, + }; + + private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) + : IPersistentProcessRuleStore + { + public IReadOnlyList SavedRules { get; private set; } = []; + + public Task> LoadAsync() => + Task.FromResult(initialRules ?? this.SavedRules); + + public Task SaveAsync(IReadOnlyList rules) + { + this.SavedRules = rules.ToList(); + return Task.CompletedTask; + } + } + + private sealed class FakeTopologyProvider(CpuTopologySnapshot topology) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) => + Task.FromResult(topology); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 42a8ae2..4f1ada1 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -294,6 +294,169 @@ public async Task ContextMenuActions_DoNotCreatePersistentRules() ruleStore.Verify(store => store.SaveAsync(It.IsAny>()), Times.Never); } + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_CreatesRuleForSelectedProcess() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(); + viewModel.SelectedProcess = process; + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = true }, + ]; + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(null); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(process.Name, rule.ProcessName); + Assert.Equal(process.ExecutablePath, rule.ExecutablePath); + Assert.Equal("Saved rule for Game.exe.", viewModel.StatusMessage); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() + { + var existing = new PersistentProcessRule + { + Id = "rule-1", + Name = "Old", + IsEnabled = true, + ProcessName = "Game.exe", + ExecutablePath = @"C:\Games\Game.exe", + CreatedAt = DateTime.UtcNow.AddDays(-1), + UpdatedAt = DateTime.UtcNow.AddDays(-1), + }; + var ruleStore = new CapturingRuleStore([existing]); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(CreateProcess(priority: ProcessPriorityClass.High)); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal("rule-1", rule.Id); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.Equal("Updated saved rule for Game.exe.", viewModel.StatusMessage); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_AppliesAffinityBeforeSavingRule() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + new CpuCoreModel { LogicalCoreId = 1, IsSelected = false }, + ]; + var process = CreateProcess(); + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(process); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + process, + It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), + "Manual Process tab context menu CPU selection", + default), + Times.Once); + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(1, rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesNotSaveRule() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = new Mock(MockBehavior.Strict); + coordinator + .Setup(service => service.ApplyCoreSelectionAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + default)) + .ReturnsAsync(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access denied.", + isAccessDenied: true)); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + ]; + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(CreateProcess()); + + Assert.Empty(ruleStore.SavedRules); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task ApplyAffinityAndSaveAsRuleCommand_UsesRowProcessInsteadOfStaleSelectedProcess() + { + var ruleStore = new CapturingRuleStore(); + var coordinator = CreateAffinityCoordinator(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + processAffinityApplyCoordinator: coordinator.Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + viewModel.CpuCores = + [ + new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, + ]; + var staleSelected = CreateProcess(name: "Old.exe", path: @"C:\Old\Old.exe"); + var rowProcess = CreateProcess(name: "Row.exe", path: @"C:\Row\Row.exe"); + viewModel.SelectedProcess = staleSelected; + + await viewModel.ApplyAffinityAndSaveAsRuleCommand.ExecuteAsync(rowProcess); + + coordinator.Verify( + service => service.ApplyCoreSelectionAsync( + rowProcess, + It.IsAny>(), + It.IsAny(), + default), + Times.Once); + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal("Row.exe", rule.ProcessName); + Assert.Equal(@"C:\Row\Row.exe", rule.ExecutablePath); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_UpdatesSelectedProcessSummary() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + Assert.True(viewModel.SelectedProcessSummary.HasThreadPilotRule); + Assert.Equal("Saved rule exists: Game.exe rule", viewModel.SelectedProcessSummary.RuleStatusText); + } + private static Mock CreateProcessService() { var processService = new Mock(MockBehavior.Loose); @@ -330,6 +493,7 @@ private static ProcessViewModel CreateViewModel( IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, IProcessMemoryPriorityService? memoryPriorityService = null, IPersistentProcessRuleStore? persistentRuleStore = null, + IProcessRuleCreationService? processRuleCreationService = null, Action? clipboardSetter = null, Action? executableLocationOpener = null) { @@ -362,6 +526,7 @@ private static ProcessViewModel CreateViewModel( memoryPriorityService: memoryPriorityService, persistentRuleStore: persistentRuleStore, persistentRuleMatcher: new PersistentProcessRuleMatcher(), + processRuleCreationService: processRuleCreationService, clipboardSetter: clipboardSetter, executableLocationOpener: executableLocationOpener); } @@ -382,5 +547,27 @@ private static ProcessModel CreateProcess( ProcessorAffinity = 0xF, Classification = ProcessClassification.ForegroundApp, }; + + private static ProcessRuleCreationService CreateRuleCreationService(IPersistentProcessRuleStore ruleStore) => + new( + ruleStore, + topologyProvider: null, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) + : IPersistentProcessRuleStore + { + public IReadOnlyList SavedRules { get; private set; } = initialRules ?? []; + + public Task> LoadAsync() => + Task.FromResult(this.SavedRules); + + public Task SaveAsync(IReadOnlyList rules) + { + this.SavedRules = rules.ToList(); + return Task.CompletedTask; + } + } } } diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index d5990b5..5f16e95 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -747,6 +747,115 @@ private async Task ApplyContextAffinity(ProcessModel? process) await this.ApplyAffinityToProcessAsync(process, "Manual Process tab context menu CPU selection"); } + [RelayCommand] + private async Task SaveCurrentSettingsAsRule(ProcessModel? process) + { + var targetProcess = process ?? this.SelectedProcess; + if (targetProcess == null) + { + return; + } + + if (this.processRuleCreationService == null) + { + this.SetContextError("Persistent rules are unavailable."); + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + return; + } + + if (!ReferenceEquals(this.SelectedProcess, targetProcess)) + { + this.SelectedProcess = targetProcess; + } + + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + + var currentCoreSelection = this.HasPendingAffinityEdits && this.CpuCores.Count > 0 + ? this.GetPendingCoreSelectionMask() + : null; + var result = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + targetProcess, + currentCoreSelection, + this.SelectedProcessSummary.MemoryPriority); + + this.ApplyRuleCreationResultStatus(result); + await this.UpdateSelectedProcessSummaryAsync(targetProcess); + } + + [RelayCommand] + private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) + { + if (process == null) + { + return; + } + + if (this.processRuleCreationService == null) + { + this.SetContextError("Persistent rules are unavailable."); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + if (!ReferenceEquals(this.SelectedProcess, process)) + { + this.SelectedProcess = process; + } + + var pendingSelection = this.GetPendingCoreSelectionMask(); + var applyResult = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + process, + pendingSelection, + "Manual Process tab context menu CPU selection"); + + if (!applyResult.Success) + { + this.SetContextError(applyResult.Message); + await this.UpdateSelectedProcessSummaryAsync(process); + return; + } + + if (!applyResult.UsedCpuSets) + { + this.UpdateCoreSelections(process.ProcessorAffinity, true); + } + + process.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + + var saveResult = applyResult.UsedCpuSets + ? await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + process, + pendingSelection, + currentMemoryPriority: null) + : await this.processRuleCreationService.SaveRuleAsync( + process, + new ProcessRuleCreationPayload + { + LegacyAffinityMask = applyResult.VerifiedMask == 0 + ? applyResult.RequestedMask + : applyResult.VerifiedMask, + }); + + this.ApplyRuleCreationResultStatus(saveResult); + await this.UpdateSelectedProcessSummaryAsync(process); + } + + private void ApplyRuleCreationResultStatus(ProcessRuleCreationResult result) + { + if (result.Success) + { + this.SetStatus(result.UserMessage, false); + return; + } + + this.SetContextError(string.IsNullOrWhiteSpace(result.UserMessage) + ? ProcessRuleCreationService.NoCurrentSettingsMessage + : result.UserMessage); + } + private async Task ApplyAffinityToProcessAsync(ProcessModel selectedProcess, string selectionReason) { try diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 34ec29b..ffa3c0c 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -48,6 +48,7 @@ public partial class ProcessViewModel : BaseViewModel private readonly IAffinityApplyService affinityApplyService; private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; private readonly IProcessMemoryPriorityService? memoryPriorityService; + private readonly IProcessRuleCreationService? processRuleCreationService; private readonly Action clipboardSetter; private readonly Action executableLocationOpener; private System.Timers.Timer? refreshTimer; @@ -188,6 +189,7 @@ public ProcessViewModel( IProcessMemoryPriorityService? memoryPriorityService = null, IPersistentProcessRuleStore? persistentRuleStore = null, IPersistentProcessRuleMatcher? persistentRuleMatcher = null, + IProcessRuleCreationService? processRuleCreationService = null, Action? clipboardSetter = null, Action? executableLocationOpener = null) : base(logger, enhancedLoggingService) @@ -212,6 +214,13 @@ public ProcessViewModel( new CpuSelectionMigrationService(), NullLogger.Instance); this.memoryPriorityService = memoryPriorityService; + this.processRuleCreationService = processRuleCreationService ?? (persistentRuleStore == null + ? null + : new ProcessRuleCreationService( + persistentRuleStore, + cpuTopologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance)); this.clipboardSetter = clipboardSetter ?? System.Windows.Clipboard.SetText; this.executableLocationOpener = executableLocationOpener ?? OpenExecutableLocationInExplorer; this.SelectedProcessSummary = new SelectedProcessSummaryViewModel( diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index b635084..897b97f 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -156,6 +156,15 @@ Command="{Binding ClearContextCpuSetsCommand}" CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/> + + + + + Date: Fri, 22 May 2026 11:40:15 +0200 Subject: [PATCH 2/2] Avoid saving normal priority in current rule capture --- Services/ProcessRuleCreationService.cs | 12 ++- .../ProcessRuleCreationServiceTests.cs | 88 +++++++++++++++++++ .../ProcessViewModelContextMenuTests.cs | 40 ++++++++- 3 files changed, 135 insertions(+), 5 deletions(-) diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index 8c6f607..2c66802 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -92,9 +92,9 @@ public async Task SaveCurrentSettingsAsRuleAsync( var payload = new ProcessRuleCreationPayload { - Priority = ProcessPriorityGuardrails.IsBlocked(process.Priority) - ? null - : process.Priority, + Priority = ShouldCaptureCurrentCpuPriority(process.Priority) + ? process.Priority + : null, MemoryPriority = currentMemoryPriority, }; @@ -248,6 +248,12 @@ private static PayloadSanitizationResult SanitizePayload(ProcessRuleCreationPayl }); } + private static bool ShouldCaptureCurrentCpuPriority(ProcessPriorityClass priority) => + priority is ProcessPriorityClass.Idle + or ProcessPriorityClass.BelowNormal + or ProcessPriorityClass.AboveNormal + or ProcessPriorityClass.High; + private static bool HasActionablePayload(ProcessRuleCreationPayload payload) => HasSelectionPayload(payload.CpuSelection) || payload.LegacyAffinityMask.HasValue || diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs index 79de6e0..0e26333 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -201,6 +201,94 @@ public async Task SaveCurrentSettingsAsRuleAsync_BlocksRealtimePriority() Assert.Empty(store.SavedRules); } + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndNoOtherPayload_ReturnsNoActionFailure() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.False(result.Success); + Assert.Equal("NoActionableRulePayload", result.ErrorCode); + Assert.Equal("There are no current settings to save as a rule.", result.UserMessage); + Assert.Empty(store.SavedRules); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithNormalPriorityAndAffinity_SavesAffinityButDoesNotEnablePriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x5, rule.LegacyAffinityMask); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithAboveNormalPriority_SavesPriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.AboveNormal, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessPriorityClass.AboveNormal, rule.Priority); + Assert.True(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithHighPriority_SavesPriority() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.High, affinity: 0), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.True(rule.ApplyPriorityOnStart); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleAsync_WithRealtimePriority_OmitsPriorityWithoutSavingIt() + { + var store = new CapturingRuleStore(); + var service = CreateService(store); + + var result = await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0x3), + currentCoreSelection: null, + currentMemoryPriority: null); + + Assert.True(result.Success); + var rule = Assert.Single(store.SavedRules); + Assert.Equal(0x3, rule.LegacyAffinityMask); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + [Fact] public async Task SaveCurrentSettingsAsRuleAsync_SavesMemoryPriorityWhenAvailable() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 4f1ada1..c591090 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -345,6 +345,41 @@ public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() Assert.Equal("Updated saved rule for Game.exe.", viewModel.StatusMessage); } + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_WithNormalPriorityAndNoAffinityOrMemoryPriority_ShowsNoActionMessage() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + Assert.Empty(ruleStore.SavedRules); + Assert.Equal("There are no current settings to save as a rule.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task SaveCurrentSettingsAsRuleCommand_WithAffinityAndNormalPriority_DoesNotSaveApplyPriorityOnStart() + { + var ruleStore = new CapturingRuleStore(); + var viewModel = CreateViewModel( + CreateProcessService().Object, + persistentRuleStore: ruleStore, + processRuleCreationService: CreateRuleCreationService(ruleStore)); + var process = CreateProcess(priority: ProcessPriorityClass.Normal, affinity: 0x5); + + await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(process); + + var rule = Assert.Single(ruleStore.SavedRules); + Assert.Equal(0x5, rule.LegacyAffinityMask); + Assert.Null(rule.Priority); + Assert.False(rule.ApplyPriorityOnStart); + } + [Fact] public async Task ApplyAffinityAndSaveAsRuleCommand_AppliesAffinityBeforeSavingRule() { @@ -535,7 +570,8 @@ private static ProcessModel CreateProcess( string name = "Game.exe", int processId = 42, string path = @"C:\Games\Game.exe", - ProcessPriorityClass priority = ProcessPriorityClass.Normal) + ProcessPriorityClass priority = ProcessPriorityClass.Normal, + long affinity = 0xF) => new() { ProcessId = processId, @@ -544,7 +580,7 @@ private static ProcessModel CreateProcess( CpuUsage = 1.5, MemoryUsage = 128 * 1024 * 1024, Priority = priority, - ProcessorAffinity = 0xF, + ProcessorAffinity = affinity, Classification = ProcessClassification.ForegroundApp, };