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..226d51a 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. 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);
+
///
/// 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..6b3beb6 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;
+ }
+
+ return false;
+ }
+
+ public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false)
+ {
+ if (this.disposed)
+ {
+ throw new ObjectDisposedException(nameof(ProcessCpuSetHandler));
+ }
+
+ if (!this.EnsureSetHandle())
+ {
+ return false;
+ }
+
+ if (clearSelection)
+ {
+ return this.ApplyCpuSetIds(null, 0, "clear CPU Set selection");
+ }
+
+ ArgumentNullException.ThrowIfNull(selection);
- error = Marshal.GetLastWin32Error();
+ 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
{
@@ -360,4 +393,3 @@ private class CpuTimeTimestamp
}
}
}
-
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs
new file mode 100644
index 0000000..54f8bb8
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs
@@ -0,0 +1,295 @@
+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_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()
+ {
+ 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;
+ }
+ }
+ }
+}