From 256088546fb75fd016eb8fc0983a65b6f759d392 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 19 May 2026 17:09:05 +0200 Subject: [PATCH 1/2] Make CPU Sets mapping group-aware --- Platforms/Windows/CpuSetMapping.cs | 129 +++++++++ Platforms/Windows/IProcessCpuSetHandler.cs | 13 +- Platforms/Windows/IProcessCpuSetNativeApi.cs | 89 ++++++ Platforms/Windows/ProcessCpuSetHandler.cs | 207 +++++++------ .../ProcessCpuSetHandlerTests.cs | 272 ++++++++++++++++++ 5 files changed, 622 insertions(+), 88 deletions(-) create mode 100644 Platforms/Windows/CpuSetMapping.cs create mode 100644 Platforms/Windows/IProcessCpuSetNativeApi.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs diff --git a/Platforms/Windows/CpuSetMapping.cs b/Platforms/Windows/CpuSetMapping.cs new file mode 100644 index 0000000..6fc8019 --- /dev/null +++ b/Platforms/Windows/CpuSetMapping.cs @@ -0,0 +1,129 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Collections.Generic; + using System.Linq; + using ThreadPilot.Models; + + internal sealed class CpuSetMapping + { + private readonly IReadOnlyDictionary cpuSetIdsByProcessor; + private readonly IReadOnlyDictionary processorsByCpuSetId; + + private CpuSetMapping( + IReadOnlyDictionary cpuSetIdsByProcessor, + IReadOnlyDictionary processorsByCpuSetId) + { + this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; + this.processorsByCpuSetId = processorsByCpuSetId; + } + + public static CpuSetMapping Empty { get; } = new( + new Dictionary(), + new Dictionary()); + + public bool IsEmpty => this.cpuSetIdsByProcessor.Count == 0; + + public static CpuSetMapping Create(IReadOnlyDictionary cpuSetIdsByProcessor) + { + ArgumentNullException.ThrowIfNull(cpuSetIdsByProcessor); + + var forwardMap = cpuSetIdsByProcessor + .OrderBy(kvp => kvp.Key.GlobalIndex) + .ThenBy(kvp => kvp.Key.Group) + .ThenBy(kvp => kvp.Key.LogicalProcessorNumber) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + var inverseMap = forwardMap + .GroupBy(kvp => kvp.Value) + .ToDictionary( + group => group.Key, + group => group + .Select(kvp => kvp.Key) + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .First()); + + return new CpuSetMapping(forwardMap, inverseMap); + } + + public static ProcessorRef CreateProcessorRef(ushort group, byte logicalProcessorNumber) + { + return new ProcessorRef(group, logicalProcessorNumber, (group * 64) + logicalProcessorNumber); + } + + public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) + { + return this.cpuSetIdsByProcessor.TryGetValue(processor, out cpuSetId); + } + + public bool TryGetProcessorRef(uint cpuSetId, out ProcessorRef processor) + { + return this.processorsByCpuSetId.TryGetValue(cpuSetId, out processor); + } + + public IReadOnlyList ResolveCpuSetIds(CpuSelection selection) + { + ArgumentNullException.ThrowIfNull(selection); + + if (selection.CpuSetIds.Count > 0) + { + return selection.CpuSetIds + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + + return selection.LogicalProcessors + .Select(processor => this.TryGetCpuSetId(processor, out var cpuSetId) ? (uint?)cpuSetId : null) + .Where(cpuSetId => cpuSetId.HasValue) + .Select(cpuSetId => cpuSetId!.Value) + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + + public IReadOnlyList ResolveLegacyAffinityMask(long affinityMask, int logicalProcessorCount) + { + var unsignedMask = unchecked((ulong)affinityMask); + var maxLegacyBits = Math.Min(Math.Max(logicalProcessorCount, 0), 64); + var cpuSetIds = new List(); + + for (var bit = 0; bit < maxLegacyBits; bit++) + { + if ((unsignedMask & (1UL << bit)) == 0) + { + continue; + } + + var processor = CreateProcessorRef(0, (byte)bit); + if (this.TryGetCpuSetId(processor, out var cpuSetId)) + { + cpuSetIds.Add(cpuSetId); + } + } + + return cpuSetIds + .Distinct() + .OrderBy(cpuSetId => cpuSetId) + .ToList(); + } + } +} diff --git a/Platforms/Windows/IProcessCpuSetHandler.cs b/Platforms/Windows/IProcessCpuSetHandler.cs index b41b1a9..acc7b7f 100644 --- a/Platforms/Windows/IProcessCpuSetHandler.cs +++ b/Platforms/Windows/IProcessCpuSetHandler.cs @@ -17,6 +17,7 @@ namespace ThreadPilot.Platforms.Windows { using System; + using ThreadPilot.Models; /// /// Interface for handling CPU Set operations on a specific process. @@ -35,12 +36,23 @@ public interface IProcessCpuSetHandler : IDisposable /// /// Applies a CPU affinity mask to the process using CPU Sets. + /// This legacy path is valid only for single-processor-group systems with up to + /// 64 logical processors. It will be superseded by + /// for topology-aware CPU Set selection. /// /// The affinity mask where each bit represents a logical processor. /// If true, clears the CPU Set (allows all cores); if false, applies the mask. /// True if the operation succeeded, false otherwise. bool ApplyCpuSetMask(long affinityMask, bool clearMask = false); + /// + /// Applies a topology-aware CPU selection to the process using CPU Sets. + /// + /// The CPU selection to apply. + /// If true, clears the CPU Set selection and ignores . + /// True if the operation succeeded, false otherwise. + bool ApplyCpuSelection(CpuSelection selection, bool clearSelection = false); + /// /// Gets the average CPU usage for this process. /// @@ -53,4 +65,3 @@ public interface IProcessCpuSetHandler : IDisposable bool IsValid { get; } } } - diff --git a/Platforms/Windows/IProcessCpuSetNativeApi.cs b/Platforms/Windows/IProcessCpuSetNativeApi.cs new file mode 100644 index 0000000..43d2b84 --- /dev/null +++ b/Platforms/Windows/IProcessCpuSetNativeApi.cs @@ -0,0 +1,89 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + internal interface IProcessCpuSetNativeApi + { + SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); + + bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount); + + bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime); + + bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags); + + int GetLastWin32Error(); + } + + internal sealed class ProcessCpuSetNativeApi : IProcessCpuSetNativeApi + { + public static ProcessCpuSetNativeApi Instance { get; } = new(); + + private ProcessCpuSetNativeApi() + { + } + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return CpuSetNativeMethods.OpenProcess(access, inheritHandle, processId); + } + + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + { + return CpuSetNativeMethods.SetProcessDefaultCpuSets(process, cpuSetIds, cpuSetIdCount); + } + + public bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime) + { + return CpuSetNativeMethods.GetProcessTimes(process, out creationTime, out exitTime, out kernelTime, out userTime); + } + + public bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags) + { + return CpuSetNativeMethods.GetSystemCpuSetInformation(information, bufferLength, ref returnedLength, process, flags); + } + + public int GetLastWin32Error() + { + return Marshal.GetLastWin32Error(); + } + } +} diff --git a/Platforms/Windows/ProcessCpuSetHandler.cs b/Platforms/Windows/ProcessCpuSetHandler.cs index 9552d5f..4e29e74 100644 --- a/Platforms/Windows/ProcessCpuSetHandler.cs +++ b/Platforms/Windows/ProcessCpuSetHandler.cs @@ -22,6 +22,7 @@ namespace ThreadPilot.Platforms.Windows using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; /// /// Handles CPU Set operations for a specific process using Windows APIs @@ -29,42 +30,48 @@ namespace ThreadPilot.Platforms.Windows /// public class ProcessCpuSetHandler : IProcessCpuSetHandler { - private static readonly Dictionary cpuSetIdPerLogicalProcessor; + private static CpuSetMapping staticCpuSetMapping = CpuSetMapping.Empty; private static readonly object staticInitLock = new object(); private static bool staticInitialized = false; private readonly Queue cpuTimeMovingAverageBuffer = new(); private readonly string executableName; private readonly uint pid; + private readonly IProcessCpuSetNativeApi nativeApi; + private readonly CpuSetMapping cpuSetMapping; private readonly ILogger? logger; private SafeProcessHandle? queryLimitedInfoHandle; private SafeProcessHandle? setLimitedInfoHandle; private bool disposed = false; - static ProcessCpuSetHandler() + public ProcessCpuSetHandler(uint processId, string executableName, ILogger? logger = null) + : this(processId, executableName, ProcessCpuSetNativeApi.Instance, EnsureStaticInitialization(ProcessCpuSetNativeApi.Instance), logger) { - cpuSetIdPerLogicalProcessor = new Dictionary(); } - public ProcessCpuSetHandler(uint processId, string executableName, ILogger? logger = null) + internal ProcessCpuSetHandler( + uint processId, + string executableName, + IProcessCpuSetNativeApi nativeApi, + CpuSetMapping cpuSetMapping, + ILogger? logger = null) { this.pid = processId; this.executableName = executableName ?? $"PID_{processId}"; + this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); + this.cpuSetMapping = cpuSetMapping ?? throw new ArgumentNullException(nameof(cpuSetMapping)); this.logger = logger; - // Initialize CPU Set mapping on first use - EnsureStaticInitialization(); - // Open handle for querying process information - this.queryLimitedInfoHandle = CpuSetNativeMethods.OpenProcess( + this.queryLimitedInfoHandle = this.nativeApi.OpenProcess( ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, false, processId); if (this.queryLimitedInfoHandle == null || this.queryLimitedInfoHandle.IsInvalid) { - var error = Marshal.GetLastWin32Error(); + var error = this.nativeApi.GetLastWin32Error(); this.logger?.LogWarning("Failed to open process {ProcessId} for querying: {Error}", processId, new Win32Exception(error).Message); } } @@ -75,34 +82,33 @@ public ProcessCpuSetHandler(uint processId, string executableName, ILogger? logg public bool IsValid => this.queryLimitedInfoHandle != null && !this.queryLimitedInfoHandle.IsInvalid; - private static void EnsureStaticInitialization() + private static CpuSetMapping EnsureStaticInitialization(IProcessCpuSetNativeApi nativeApi) { if (staticInitialized) { - return; + return staticCpuSetMapping; } lock (staticInitLock) { if (staticInitialized) { - return; + return staticCpuSetMapping; } try { - var mapping = GetCpuSetIdPerLogicalProcessor(); - foreach (var kvp in mapping) - { - cpuSetIdPerLogicalProcessor[kvp.Key] = kvp.Value; - } - staticInitialized = true; + staticCpuSetMapping = GetCpuSetMapping(nativeApi); } catch (Exception) { // If we can't get CPU Set mapping, CPU Sets won't be available // The handler will still work but ApplyCpuSetMask will return false + staticCpuSetMapping = CpuSetMapping.Empty; } + + staticInitialized = true; + return staticCpuSetMapping; } } @@ -132,7 +138,7 @@ public double GetAverageCpuUsage() } // Get the current total CPU time of the process - bool success = CpuSetNativeMethods.GetProcessTimes( + bool success = this.nativeApi.GetProcessTimes( this.queryLimitedInfoHandle, out _, out _, @@ -182,134 +188,160 @@ public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); } - // Ensure we have CPU Set mapping - if (cpuSetIdPerLogicalProcessor.Count == 0) + // Legacy mask support is intentionally limited to single-group systems where + // logical processors 0-63 map to processor group 0. CpuSelection will replace + // this path for group-aware selections in a later phase. + if (this.cpuSetMapping.IsEmpty) { this.logger?.LogWarning("CPU Set mapping not available. Cannot apply CPU Sets to process {ProcessId}", this.pid); return false; } - // Open handle for setting process information if not already open - if (this.setLimitedInfoHandle == null) + if (!this.EnsureSetHandle()) { - this.setLimitedInfoHandle = CpuSetNativeMethods.OpenProcess( - ProcessAccessFlags.PROCESS_SET_LIMITED_INFORMATION, - false, - this.pid); + return false; + } - if (this.setLimitedInfoHandle == null || this.setLimitedInfoHandle.IsInvalid) - { - int openError = Marshal.GetLastWin32Error(); - string extraHelpString = (openError == 5) ? " Try restarting as Administrator" : string.Empty; - this.logger?.LogWarning( - "Could not open process '{ExecutableName}' (PID: {ProcessId}) for setting affinity: {Error}{Help}", - this.executableName, this.pid, new Win32Exception(openError).Message, extraHelpString); - return false; - } + if (clearMask) + { + return this.ApplyCpuSetIds(null, 0, "clear CPU Set"); } - else if (this.setLimitedInfoHandle.IsInvalid) + + var cpuSetIds = this.cpuSetMapping.ResolveLegacyAffinityMask(affinityMask, Environment.ProcessorCount); + + if (cpuSetIds.Count == 0) { - // The handle was already made previously and failed, don't bother trying again + this.logger?.LogWarning( + "No valid CPU Set IDs found for affinity mask 0x{AffinityMask:X} on process '{ExecutableName}'", + affinityMask, this.executableName); return false; } - bool success; - int error; + var cpuSetIdsArray = cpuSetIds.ToArray(); + var success = this.ApplyCpuSetIds(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set"); - if (clearMask) + if (success) { - // Clear the CPU Set (allow process to run on all cores) - success = CpuSetNativeMethods.SetProcessDefaultCpuSets(this.setLimitedInfoHandle, null, 0); - if (success) - { - this.logger?.LogInformation("Cleared CPU Set of '{ExecutableName}' (PID: {ProcessId})", this.executableName, this.pid); - return true; - } + this.logger?.LogInformation( + "Applied CPU Set (affinity mask 0x{AffinityMask:X}) to '{ExecutableName}' (PID: {ProcessId})", + affinityMask, this.executableName, this.pid); + return true; + } - error = Marshal.GetLastWin32Error(); + return false; + } + + public bool ApplyCpuSelection(CpuSelection selection, bool clearSelection = false) + { + ArgumentNullException.ThrowIfNull(selection); + + if (this.disposed) + { + throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); + } + + if (!this.EnsureSetHandle()) + { + return false; + } + + if (clearSelection) + { + return this.ApplyCpuSetIds(null, 0, "clear CPU Set selection"); + } + + var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); + if (cpuSetIds.Count == 0) + { this.logger?.LogWarning( - "Could not clear CPU Set of '{ExecutableName}' (PID: {ProcessId}): {Error}", - this.executableName, this.pid, new Win32Exception(error).Message); + "No valid CPU Set IDs resolved for CPU selection on process '{ExecutableName}' (PID: {ProcessId})", + this.executableName, + this.pid); return false; } - // Convert affinity mask to CPU Set IDs - List cpuSetIds = new List(); - int logicalCoreCount = Environment.ProcessorCount; + var cpuSetIdsArray = cpuSetIds.ToArray(); + return this.ApplyCpuSetIds(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection"); + } - for (int i = 0; i < logicalCoreCount; i++) + private bool EnsureSetHandle() + { + if (this.setLimitedInfoHandle == null) { - long coreBit = 1L << i; - if ((affinityMask & coreBit) != 0) + this.setLimitedInfoHandle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_SET_LIMITED_INFORMATION, + false, + this.pid); + + if (this.setLimitedInfoHandle == null || this.setLimitedInfoHandle.IsInvalid) { - if (cpuSetIdPerLogicalProcessor.TryGetValue(i, out uint cpuSetId)) - { - cpuSetIds.Add(cpuSetId); - } - else - { - this.logger?.LogWarning( - "Unable to include core {CoreIndex} in CPU Set for '{ExecutableName}'. It does not have a CPU Set ID", - i, this.executableName); - } + int openError = this.nativeApi.GetLastWin32Error(); + string extraHelpString = (openError == 5) ? " Try restarting as Administrator" : string.Empty; + this.logger?.LogWarning( + "Could not open process '{ExecutableName}' (PID: {ProcessId}) for setting affinity: {Error}{Help}", + this.executableName, this.pid, new Win32Exception(openError).Message, extraHelpString); + return false; } } - - if (cpuSetIds.Count == 0) + else if (this.setLimitedInfoHandle.IsInvalid) { - this.logger?.LogWarning( - "No valid CPU Set IDs found for affinity mask 0x{AffinityMask:X} on process '{ExecutableName}'", - affinityMask, this.executableName); + // The handle was already made previously and failed, don't bother trying again return false; } - uint[] cpuSetIdsArray = cpuSetIds.ToArray(); - success = CpuSetNativeMethods.SetProcessDefaultCpuSets(this.setLimitedInfoHandle, cpuSetIdsArray, (uint)cpuSetIdsArray.Length); + return true; + } + private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) + { + bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount); if (success) { this.logger?.LogInformation( - "Applied CPU Set (affinity mask 0x{AffinityMask:X}) to '{ExecutableName}' (PID: {ProcessId})", - affinityMask, this.executableName, this.pid); + "Completed {OperationName} for '{ExecutableName}' (PID: {ProcessId})", + operationName, + this.executableName, + this.pid); return true; } - error = Marshal.GetLastWin32Error(); - string errorMessage = $"Could not apply CPU Set to '{this.executableName}' (PID: {this.pid}): {new Win32Exception(error).Message}"; + int error = this.nativeApi.GetLastWin32Error(); + string errorMessage = $"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(error).Message}"; if (error == 5) { errorMessage += " (Likely due to anti-cheat or insufficient privileges)"; } + this.logger?.LogWarning(errorMessage); return false; } /// - /// Get the CPU Set Id of each logical processor. + /// Gets the CPU Set ID of each logical processor keyed by processor group and group-relative logical processor number. /// - private static Dictionary GetCpuSetIdPerLogicalProcessor() + private static CpuSetMapping GetCpuSetMapping(IProcessCpuSetNativeApi nativeApi) { uint bufferLength = 0; // First call to get buffer size - if (!CpuSetNativeMethods.GetSystemCpuSetInformation(IntPtr.Zero, 0, ref bufferLength, new SafeProcessHandle(), 0)) + if (!nativeApi.GetSystemCpuSetInformation(IntPtr.Zero, 0, ref bufferLength, new SafeProcessHandle(), 0)) { - int error = Marshal.GetLastWin32Error(); + int error = nativeApi.GetLastWin32Error(); if (error != 0x7A) // ERROR_INSUFFICIENT_BUFFER { throw new Win32Exception(error, "Failed to query CPU Set information buffer size"); } } - Dictionary cpuSets = new Dictionary(); + Dictionary cpuSets = new Dictionary(); IntPtr buffer = Marshal.AllocHGlobal((int)bufferLength); try { // Second call to get actual data - if (!CpuSetNativeMethods.GetSystemCpuSetInformation(buffer, bufferLength, ref bufferLength, new SafeProcessHandle(), 0)) + if (!nativeApi.GetSystemCpuSetInformation(buffer, bufferLength, ref bufferLength, new SafeProcessHandle(), 0)) { - throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to get CPU Set information"); + throw new Win32Exception(nativeApi.GetLastWin32Error(), "Failed to get CPU Set information"); } IntPtr current = buffer; @@ -324,12 +356,13 @@ private static Dictionary GetCpuSetIdPerLogicalProcessor() throw new InvalidCastException("Invalid CPU Set information type encountered"); } - cpuSets[item.LogicalProcessorIndex] = item.Id; + var processor = CpuSetMapping.CreateProcessorRef(item.Group, item.LogicalProcessorIndex); + cpuSets[processor] = item.Id; current = IntPtr.Add(current, (int)item.Size); } - return cpuSets; + return CpuSetMapping.Create(cpuSets); } finally { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs new file mode 100644 index 0000000..f4d7e14 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs @@ -0,0 +1,272 @@ +namespace ThreadPilot.Core.Tests +{ + using System; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public sealed class ProcessCpuSetHandlerTests + { + [Fact] + public void CpuSetMapping_KeepsSameLogicalProcessorIndexInDifferentGroupsDistinct() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var mapping = CpuSetMapping.Create(new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + Assert.True(mapping.TryGetCpuSetId(group0Cpu0, out var group0CpuSetId)); + Assert.True(mapping.TryGetCpuSetId(group1Cpu0, out var group1CpuSetId)); + Assert.Equal(100U, group0CpuSetId); + Assert.Equal(200U, group1CpuSetId); + Assert.True(mapping.TryGetProcessorRef(100, out var group0Processor)); + Assert.True(mapping.TryGetProcessorRef(200, out var group1Processor)); + Assert.Equal(group0Cpu0, group0Processor); + Assert.Equal(group1Cpu0, group1Processor); + } + + [Fact] + public void CpuSetMapping_Cpu64DoesNotSelectCpu0() + { + var group0Cpu0 = new ProcessorRef(0, 0, 0); + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create( + [group0Cpu0, group1Cpu0], + cpuSetIds: new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + var selection = CpuSelection.FromProcessors([group1Cpu0], topology); + var mapping = CpuSetMapping.Create(new Dictionary + { + [group0Cpu0] = 100, + [group1Cpu0] = 200, + }); + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([200U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_UsesExplicitCpuSetIds() + { + var mapping = CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + }); + var selection = new CpuSelection + { + CpuSetIds = [300, 100, 300], + LogicalProcessors = [new ProcessorRef(0, 0, 0)], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([100U, 300U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_MapsProcessorRefsWhenCpuSetIdsAreMissing() + { + var cpu1 = new ProcessorRef(0, 1, 1); + var mapping = CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [cpu1] = 101, + }); + var selection = new CpuSelection + { + LogicalProcessors = [cpu1], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Equal([101U], cpuSetIds); + } + + [Fact] + public void CpuSetMapping_ResolveSelection_ReturnsEmptyWhenNoMappingExists() + { + var mapping = CpuSetMapping.Empty; + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(0, 1, 1)], + }; + + var cpuSetIds = mapping.ResolveCpuSetIds(selection); + + Assert.Empty(cpuSetIds); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelection_ClearsCpuSets() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelection(new CpuSelection(), clearSelection: true); + + Assert.True(result); + Assert.Null(nativeApi.LastAppliedCpuSetIds); + Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithExplicitCpuSetIds_AppliesThoseIds() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + CpuSetIds = [400, 200, 400], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.True(result); + Assert.Equal([200U, 400U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutCpuSetIds_ResolvesProcessorRefs() + { + var cpu64 = new ProcessorRef(1, 0, 64); + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [cpu64] = 200, + })); + var selection = new CpuSelection + { + LogicalProcessors = [cpu64], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.True(result); + Assert.Equal([200U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutResolvableCpuSets_ReturnsFalse() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + var selection = new CpuSelection + { + LogicalProcessors = [new ProcessorRef(1, 0, 64)], + }; + + var result = handler.ApplyCpuSelection(selection); + + Assert.False(result); + Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacySingleGroupMappingIsPreserved() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [new ProcessorRef(0, 1, 1)] = 101, + [new ProcessorRef(1, 0, 64)] = 200, + })); + + var result = handler.ApplyCpuSetMask(0b11); + + Assert.True(result); + Assert.Equal([100U, 101U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacyCpu0BitDoesNotRepresentGroup1Cpu0() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler( + nativeApi, + CpuSetMapping.Create(new Dictionary + { + [new ProcessorRef(0, 0, 0)] = 100, + [new ProcessorRef(1, 0, 64)] = 200, + })); + + var result = handler.ApplyCpuSetMask(0b1); + + Assert.True(result); + Assert.Equal([100U], nativeApi.LastAppliedCpuSetIds!); + } + + private static ProcessCpuSetHandler CreateHandler( + FakeProcessCpuSetNativeApi nativeApi, + CpuSetMapping mapping) + { + return new ProcessCpuSetHandler(1234, "test.exe", nativeApi, mapping); + } + + private sealed class FakeProcessCpuSetNativeApi : IProcessCpuSetNativeApi + { + public bool WasSetProcessDefaultCpuSetsCalled { get; private set; } + + public uint[]? LastAppliedCpuSetIds { get; private set; } + + public uint LastAppliedCpuSetCount { get; private set; } + + public int LastWin32Error { get; set; } + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); + } + + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + { + this.WasSetProcessDefaultCpuSetsCalled = true; + this.LastAppliedCpuSetIds = cpuSetIds; + this.LastAppliedCpuSetCount = cpuSetIdCount; + return true; + } + + public bool GetProcessTimes( + SafeProcessHandle process, + out FILETIME creationTime, + out FILETIME exitTime, + out FILETIME kernelTime, + out FILETIME userTime) + { + creationTime = default; + exitTime = default; + kernelTime = default; + userTime = default; + return false; + } + + public bool GetSystemCpuSetInformation( + IntPtr information, + uint bufferLength, + ref uint returnedLength, + SafeProcessHandle process, + uint flags) + { + returnedLength = 0; + return false; + } + + public int GetLastWin32Error() + { + return this.LastWin32Error; + } + } + } +} From 153b2fe266fb2bdb60c4de008ee7d03e4056e73c Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Tue, 19 May 2026 17:12:43 +0200 Subject: [PATCH 2/2] Allow clearing CPU selection without selection payload --- Platforms/Windows/IProcessCpuSetHandler.cs | 4 ++-- Platforms/Windows/ProcessCpuSetHandler.cs | 7 +++--- .../ProcessCpuSetHandlerTests.cs | 23 +++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Platforms/Windows/IProcessCpuSetHandler.cs b/Platforms/Windows/IProcessCpuSetHandler.cs index acc7b7f..226d51a 100644 --- a/Platforms/Windows/IProcessCpuSetHandler.cs +++ b/Platforms/Windows/IProcessCpuSetHandler.cs @@ -48,10 +48,10 @@ public interface IProcessCpuSetHandler : IDisposable /// /// Applies a topology-aware CPU selection to the process using CPU Sets. /// - /// The CPU selection to apply. + /// The CPU selection to apply. Ignored and allowed to be null when is true. /// If true, clears the CPU Set selection and ignores . /// True if the operation succeeded, false otherwise. - bool ApplyCpuSelection(CpuSelection selection, bool clearSelection = false); + bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false); /// /// Gets the average CPU usage for this process. diff --git a/Platforms/Windows/ProcessCpuSetHandler.cs b/Platforms/Windows/ProcessCpuSetHandler.cs index 4e29e74..6b3beb6 100644 --- a/Platforms/Windows/ProcessCpuSetHandler.cs +++ b/Platforms/Windows/ProcessCpuSetHandler.cs @@ -231,10 +231,8 @@ public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) return false; } - public bool ApplyCpuSelection(CpuSelection selection, bool clearSelection = false) + public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) { - ArgumentNullException.ThrowIfNull(selection); - if (this.disposed) { throw new ObjectDisposedException(nameof(ProcessCpuSetHandler)); @@ -250,6 +248,8 @@ public bool ApplyCpuSelection(CpuSelection selection, bool clearSelection = fals return this.ApplyCpuSetIds(null, 0, "clear CPU Set selection"); } + ArgumentNullException.ThrowIfNull(selection); + var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); if (cpuSetIds.Count == 0) { @@ -393,4 +393,3 @@ private class CpuTimeTimestamp } } } - diff --git a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs index f4d7e14..54f8bb8 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs @@ -116,6 +116,29 @@ public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelection_ClearsCpuS Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); } + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithClearSelectionAndNullSelection_ClearsCpuSets() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelection(null!, clearSelection: true); + + Assert.True(result); + Assert.Null(nativeApi.LastAppliedCpuSetIds); + Assert.Equal(0U, nativeApi.LastAppliedCpuSetCount); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_WithNullSelectionAndClearFalse_ThrowsArgumentNullException() + { + var nativeApi = new FakeProcessCpuSetNativeApi(); + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + Assert.Throws(() => + handler.ApplyCpuSelection(null!, clearSelection: false)); + } + [Fact] public void ProcessCpuSetHandler_ApplyCpuSelection_WithExplicitCpuSetIds_AppliesThoseIds() {