diff --git a/Models/CoreMask.cs b/Models/CoreMask.cs index 5d3f0ef..b61cfdc 100644 --- a/Models/CoreMask.cs +++ b/Models/CoreMask.cs @@ -71,7 +71,9 @@ public partial class CoreMask : ObservableObject public int SelectedCoreCount => this.BoolMask.Count(b => b); /// - /// Converts the boolean mask to a processor affinity value. + /// Converts the boolean mask to a legacy 64-bit processor affinity value. + /// This is only safe for single processor-group selections below CPU 64; + /// topology-aware apply paths must prefer . /// public long ToProcessorAffinity() { @@ -162,4 +164,3 @@ public override string ToString() } } } - diff --git a/Services/ProcessAffinityApplyCoordinator.cs b/Services/ProcessAffinityApplyCoordinator.cs new file mode 100644 index 0000000..66cf0c0 --- /dev/null +++ b/Services/ProcessAffinityApplyCoordinator.cs @@ -0,0 +1,178 @@ +/* + * ThreadPilot - process tab affinity apply coordination. + */ +namespace ThreadPilot.Services +{ + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IProcessAffinityApplyCoordinator + { + Task ApplyCoreMaskAsync( + ProcessModel process, + CoreMask coreMask, + CancellationToken cancellationToken = default); + + Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default); + } + + public sealed class ProcessAffinityApplyCoordinator : IProcessAffinityApplyCoordinator + { + private readonly IAffinityApplyService affinityApplyService; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; + private readonly ILogger logger; + + public ProcessAffinityApplyCoordinator( + IAffinityApplyService affinityApplyService, + ICpuTopologyProvider? cpuTopologyProvider, + CpuSelectionMigrationService cpuSelectionMigrationService, + ILogger logger) + { + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? throw new ArgumentNullException(nameof(cpuSelectionMigrationService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task ApplyCoreMaskAsync( + ProcessModel process, + CoreMask coreMask, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(coreMask); + + if (HasSelectionPayload(coreMask.CpuSelection)) + { + return this.affinityApplyService.ApplyAsync(process, coreMask.CpuSelection!); + } + + return this.ApplyCoreSelectionAsync( + process, + coreMask.BoolMask.ToList(), + $"Manual Process tab mask '{coreMask.Name}'", + cancellationToken); + } + + public async Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + ArgumentNullException.ThrowIfNull(boolMask); + + if (boolMask.Count == 0 || !boolMask.Any(selected => selected)) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + "Manual CPU selection is empty.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + } + + var migratedSelection = await this.TryMigrateToCpuSelectionAsync( + boolMask, + selectionReason, + cancellationToken).ConfigureAwait(false); + if (migratedSelection != null) + { + return await this.affinityApplyService.ApplyAsync(process, migratedSelection).ConfigureAwait(false); + } + + if (!TryBuildSafeLegacyMask(boolMask, out var legacyMask, out var legacyFailure)) + { + return legacyFailure; + } + + return await this.affinityApplyService.ApplyAsync(process, legacyMask).ConfigureAwait(false); + } + + private async Task TryMigrateToCpuSelectionAsync( + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken) + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + var topology = await this.cpuTopologyProvider.GetTopologySnapshotAsync(cancellationToken).ConfigureAwait(false); + var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask(boolMask, topology); + if (!HasSelectionPayload(migrated.Selection)) + { + return null; + } + + return migrated.Selection with + { + Metadata = migrated.Selection.Metadata with + { + SelectionReason = string.IsNullOrWhiteSpace(selectionReason) + ? migrated.Selection.Metadata.SelectionReason + : selectionReason, + }, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not migrate manual Process tab CPU selection to CpuSelection"); + return null; + } + } + + private static bool HasSelectionPayload(CpuSelection? selection) => + selection != null && + (selection.CpuSetIds.Count > 0 || selection.LogicalProcessors.Count > 0); + + private static bool TryBuildSafeLegacyMask( + IReadOnlyList boolMask, + out long legacyMask, + out AffinityApplyResult failure) + { + legacyMask = 0; + failure = default!; + + if (boolMask.Count > 64) + { + failure = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.LegacyFallbackUnsafe, + ProcessOperationUserMessages.LegacyFallbackBlocked, + "Manual CPU selection exceeds the legacy single-group 64-bit affinity mask.", + isLegacyFallbackBlocked: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + return false; + } + + for (var bit = 0; bit < boolMask.Count; bit++) + { + if (boolMask[bit]) + { + legacyMask |= 1L << bit; + } + } + + if (legacyMask == 0) + { + failure = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + "Manual CPU selection does not contain any enabled CPUs.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask); + return false; + } + + return true; + } + } +} diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 5e6c007..2b86b48 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -106,6 +106,7 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(ProcessMemoryPriorityNativeApi.Instance); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs new file mode 100644 index 0000000..4c72b7e --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessAffinityApplyCoordinatorTests.cs @@ -0,0 +1,274 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessAffinityApplyCoordinatorTests + { + [Fact] + public async Task ApplyCoreMaskAsync_WithCpuSelection_UsesCpuSelectionPath() + { + var process = CreateProcess(); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 2, 2)], + GlobalLogicalProcessorIndexes = [0, 2], + }; + var mask = CreateMask([true, false, true]); + mask.CpuSelection = selection; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Same(selection, affinity.LastSelection); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithCpu64Selection_DoesNotUseLegacyMaskOrAliasCpu0() + { + var process = CreateProcess(); + var cpu64 = new ProcessorRef(1, 0, 64); + var mask = CreateMask(Enumerable.Range(0, 65).Select(index => index == 64).ToList()); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [cpu64], + GlobalLogicalProcessorIndexes = [64], + }; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(0, affinity.LegacyApplyCalls); + var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); + Assert.Equal(cpu64, applied); + Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithMultiGroupSelection_DoesNotUseLegacyMask() + { + var process = CreateProcess(); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0), new ProcessorRef(1, 0, 64)], + GlobalLogicalProcessorIndexes = [0, 64], + }; + var mask = CreateMask(Enumerable.Repeat(true, 65).ToList()); + mask.CpuSelection = selection; + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithoutCpuSelectionAndWithoutTopology_UsesLegacyForSingleGroupMask() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, topologyProvider: null); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); + + Assert.True(result.Success); + Assert.Equal(1, affinity.LegacyApplyCalls); + Assert.Equal(0b101, affinity.LastLegacyMask); + Assert.Equal(0, affinity.CpuSelectionApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithoutCpuSelection_MigratesToCpuSelectionWhenTopologyIsAvailable() + { + var process = CreateProcess(); + var topology = CpuTopologySnapshot.Create( + [new ProcessorRef(0, 0, 0), new ProcessorRef(0, 1, 1), new ProcessorRef(0, 2, 2)]); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask([true, false, true])); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Equal([0, 2], affinity.LastSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WithCpu64BoolMaskAndTopology_UsesCpuSelectionPath() + { + var process = CreateProcess(); + var processors = Enumerable.Range(0, 65) + .Select(index => index < 64 + ? new ProcessorRef(0, (byte)index, index) + : new ProcessorRef(1, 0, index)) + .ToList(); + var topology = CpuTopologySnapshot.Create(processors); + var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, new FakeCpuTopologyProvider(topology)); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); + + Assert.True(result.Success); + Assert.Equal(1, affinity.CpuSelectionApplyCalls); + Assert.Equal(0, affinity.LegacyApplyCalls); + var applied = Assert.Single(affinity.LastSelection!.LogicalProcessors); + Assert.Equal(new ProcessorRef(1, 0, 64), applied); + Assert.DoesNotContain(affinity.LastSelection.LogicalProcessors, processor => processor.GlobalIndex == 0); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenTopologyUnavailableAndMaskIsUnsafe_BlocksLegacyFallback() + { + var process = CreateProcess(); + var boolMask = Enumerable.Range(0, 65).Select(index => index == 64).ToList(); + var affinity = new RecordingAffinityApplyService(); + var coordinator = CreateCoordinator(affinity, topologyProvider: null); + + var result = await coordinator.ApplyCoreMaskAsync(process, CreateMask(boolMask)); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage); + Assert.Equal(0, affinity.LegacyApplyCalls); + Assert.Equal(0, affinity.CpuSelectionApplyCalls); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenCpuSelectionAccessDenied_ReturnsSafeAccessDeniedMessage() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService + { + CpuSelectionResult = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true), + }; + var mask = CreateMask([true]); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyCoreMaskAsync_WhenCpuSelectionAntiCheatBlocked_ReturnsNoBypassMessage() + { + var process = CreateProcess(); + var affinity = new RecordingAffinityApplyService + { + CpuSelectionResult = AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + "Protected process.", + isAccessDenied: true, + isAntiCheatLikely: true), + }; + var mask = CreateMask([true]); + mask.CpuSelection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + GlobalLogicalProcessorIndexes = [0], + }; + var coordinator = CreateCoordinator(affinity); + + var result = await coordinator.ApplyCoreMaskAsync(process, mask); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); + Assert.Contains("will not try to bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + private static ProcessAffinityApplyCoordinator CreateCoordinator( + RecordingAffinityApplyService affinity, + ICpuTopologyProvider? topologyProvider = null) => + new( + affinity, + topologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ProcessorAffinity = 1, + }; + + private static CoreMask CreateMask(IReadOnlyList boolMask) + { + var mask = new CoreMask { Name = "Manual" }; + foreach (var bit in boolMask) + { + mask.BoolMask.Add(bit); + } + + return mask; + } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(snapshot); + } + } + + private sealed class RecordingAffinityApplyService : IAffinityApplyService + { + public int LegacyApplyCalls { get; private set; } + + public int CpuSelectionApplyCalls { get; private set; } + + public long? LastLegacyMask { get; private set; } + + public CpuSelection? LastSelection { get; private set; } + + public AffinityApplyResult LegacyResult { get; init; } = + AffinityApplyResult.SucceededWithLegacyFallback(1, 1); + + public AffinityApplyResult CpuSelectionResult { get; init; } = + AffinityApplyResult.SucceededWithCpuSets("CPU Sets applied."); + + public Task ApplyAsync(ProcessModel process, long requestedMask) + { + this.LegacyApplyCalls++; + this.LastLegacyMask = requestedMask; + return Task.FromResult(this.LegacyResult); + } + + public Task ApplyAsync(ProcessModel process, CpuSelection selection) + { + this.CpuSelectionApplyCalls++; + this.LastSelection = selection; + return Task.FromResult(this.CpuSelectionResult); + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs index 513ee29..a8c4a54 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs @@ -61,7 +61,39 @@ public async Task SelectingCoreMask_ReportsPendingAffinityWithoutChangingCurrent Assert.Equal("Core mask staged. Use Apply Affinity to change Windows affinity.", viewModel.AffinityEditStateText); } + [Fact] + public void ConstructorFallbackCoordinator_ReceivesTopologyProviderWhenProvided() + { + var processService = new Mock(MockBehavior.Loose); + var gameModeService = new Mock(MockBehavior.Loose); + var topologyProvider = new Mock(MockBehavior.Strict); + + var viewModel = CreateViewModel( + processService.Object, + gameModeService.Object, + cpuTopologyProvider: topologyProvider.Object); + + var coordinator = typeof(ProcessViewModel) + .GetField( + "processAffinityApplyCoordinator", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(viewModel); + var provider = typeof(ProcessAffinityApplyCoordinator) + .GetField( + "cpuTopologyProvider", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(coordinator); + + Assert.Same(topologyProvider.Object, provider); + } + private static ProcessViewModel CreateViewModel(IProcessService processService, IGameModeService gameModeService) + => CreateViewModel(processService, gameModeService, cpuTopologyProvider: null); + + private static ProcessViewModel CreateViewModel( + IProcessService processService, + IGameModeService gameModeService, + ICpuTopologyProvider? cpuTopologyProvider) { var virtualizedProcessService = new Mock(MockBehavior.Loose); virtualizedProcessService.SetupProperty( @@ -86,7 +118,8 @@ private static ProcessViewModel CreateViewModel(IProcessService processService, systemTrayService.Object, coreMaskService.Object, associationService.Object, - gameModeService); + gameModeService, + cpuTopologyProvider: cpuTopologyProvider); } private static CpuTopologyModel CreateTwoCoreTopology() diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 47f1f33..b9b0e72 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -460,6 +460,14 @@ private long CalculateAffinityMask() return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); } + private List GetPendingCoreSelectionMask() + { + return this.CpuCores + .OrderBy(core => core.LogicalCoreId) + .Select(core => core.IsSelected) + .ToList(); + } + private void UpdateAffinityDisplayState() { var currentMask = this.SelectedProcess?.ProcessorAffinity; @@ -697,18 +705,25 @@ private async Task SetAffinity() try { - var affinityMask = this.CalculateAffinityMask(); + var pendingSelection = this.GetPendingCoreSelectionMask(); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); }); - var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask); + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + "Manual Process tab CPU selection"); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + selectedProcess.ForceNotifyProcessorAffinityChanged(); this.OnPropertyChanged(nameof(this.SelectedProcess)); @@ -899,26 +914,20 @@ private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) "Applying mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", mask.Name, selectedProcess.Name, selectedProcess.ProcessId); - // Convert mask to affinity - long affinity = mask.ToProcessorAffinity(); - - if (affinity == 0) - { - this.Logger.LogWarning("Mask '{MaskName}' produces zero affinity, skipping", mask.Name); - this.SetStatus("Invalid mask: no cores selected"); - return; - } - // Disable Windows Game Mode for better CPU affinity control // Game Mode can interfere with CPU Sets, particularly on AMD systems await this.gameModeService.DisableGameModeForAffinityAsync(); - var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinity); + var result = await this.processAffinityApplyCoordinator.ApplyCoreMaskAsync(selectedProcess, mask); System.Windows.Application.Current.Dispatcher.Invoke(() => { selectedProcess.ForceNotifyProcessorAffinityChanged(); - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + this.OnPropertyChanged(nameof(this.SelectedProcess)); }); @@ -988,21 +997,30 @@ private async Task QuickApplyAffinityAndPowerPlan() try { + var affinityAppliedWithCpuSets = false; + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.SetStatus($"Applying pending settings to {selectedProcess.Name}..."); }); // Apply CPU affinity - var affinityMask = this.CalculateAffinityMask(); - if (affinityMask > 0) + var pendingSelection = this.GetPendingCoreSelectionMask(); + if (pendingSelection.Any(selected => selected)) { - var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask); + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + "Manual Process tab quick apply CPU selection"); if (!result.Success) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + if (!result.UsedCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + selectedProcess.ForceNotifyProcessorAffinityChanged(); this.OnPropertyChanged(nameof(this.SelectedProcess)); this.SetStatus(result.Message, false); @@ -1012,6 +1030,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); + affinityAppliedWithCpuSets = result.UsedCpuSets; } // Apply power plan if selected @@ -1024,7 +1043,15 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + if (!affinityAppliedWithCpuSets) + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + } + else + { + this.UpdateAffinityDisplayState(); + } + selectedProcess.ForceNotifyProcessorAffinityChanged(); this.OnPropertyChanged(nameof(this.SelectedProcess)); }); @@ -1786,4 +1813,3 @@ protected override void OnDispose() } } } - diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 6e0d754..ba40e8a 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -45,6 +45,7 @@ public partial class ProcessViewModel : BaseViewModel private readonly IProcessPowerPlanAssociationService associationService; private readonly IGameModeService gameModeService; private readonly IAffinityApplyService affinityApplyService; + private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; private System.Timers.Timer? refreshTimer; private bool isUiRefreshPaused; private bool isProcessViewActive = true; @@ -177,6 +178,8 @@ public ProcessViewModel( IProcessPowerPlanAssociationService associationService, IGameModeService gameModeService, IAffinityApplyService? affinityApplyService = null, + IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, + ICpuTopologyProvider? cpuTopologyProvider = null, IEnhancedLoggingService? enhancedLoggingService = null) : base(logger, enhancedLoggingService) { @@ -194,6 +197,11 @@ public ProcessViewModel( this.processService, this.cpuTopologyService, NullLogger.Instance); + this.processAffinityApplyCoordinator = processAffinityApplyCoordinator ?? new ProcessAffinityApplyCoordinator( + this.affinityApplyService, + cpuTopologyProvider, + new CpuSelectionMigrationService(), + NullLogger.Instance); // Subscribe to topology detection events this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; @@ -217,4 +225,3 @@ public ProcessViewModel( } } } -