Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Platforms/Windows/CpuSetApplyResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace ThreadPilot.Platforms.Windows
{
using ThreadPilot.Services;

public sealed record CpuSetApplyResult
{
public bool Success { get; init; }

public string ErrorCode { get; init; } = AffinityApplyErrorCodes.None;

public int Win32ErrorCode { get; init; }

public string UserMessage { get; init; } = string.Empty;

public string TechnicalMessage { get; init; } = string.Empty;

public bool IsAccessDenied { get; init; }

public bool IsAntiCheatLikely { get; init; }

public static CpuSetApplyResult Succeeded(string technicalMessage) =>
new()
{
Success = true,
TechnicalMessage = technicalMessage,
};

public static CpuSetApplyResult Failed(
string errorCode,
string userMessage,
string technicalMessage,
int win32ErrorCode = 0,
bool isAccessDenied = false,
bool isAntiCheatLikely = false) =>
new()
{
Success = false,
ErrorCode = errorCode,
UserMessage = userMessage,
TechnicalMessage = technicalMessage,
Win32ErrorCode = win32ErrorCode,
IsAccessDenied = isAccessDenied,
IsAntiCheatLikely = isAntiCheatLikely,
};
}
}
16 changes: 16 additions & 0 deletions Platforms/Windows/IProcessCpuSetHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ public interface IProcessCpuSetHandler : IDisposable
/// <returns>True if the operation succeeded, false otherwise.</returns>
bool ApplyCpuSetMask(long affinityMask, bool clearMask = false);

/// <summary>
/// Applies a CPU affinity mask to the process using CPU Sets and returns detailed failure information.
/// </summary>
/// <param name="affinityMask">The affinity mask where each bit represents a logical processor.</param>
/// <param name="clearMask">If true, clears the CPU Set (allows all cores); if false, applies the mask.</param>
/// <returns>Detailed CPU Set apply result.</returns>
CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false);

/// <summary>
/// Applies a topology-aware CPU selection to the process using CPU Sets.
/// </summary>
Expand All @@ -53,6 +61,14 @@ public interface IProcessCpuSetHandler : IDisposable
/// <returns>True if the operation succeeded, false otherwise.</returns>
bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false);

/// <summary>
/// Applies a topology-aware CPU selection to the process using CPU Sets and returns detailed failure information.
/// </summary>
/// <param name="selection">The CPU selection to apply. Ignored and allowed to be null when <paramref name="clearSelection"/> is true.</param>
/// <param name="clearSelection">If true, clears the CPU Set selection and ignores <paramref name="selection"/>.</param>
/// <returns>Detailed CPU Set apply result.</returns>
CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false);

/// <summary>
/// Gets the average CPU usage for this process.
/// </summary>
Expand Down
98 changes: 75 additions & 23 deletions Platforms/Windows/ProcessCpuSetHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ namespace ThreadPilot.Platforms.Windows
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using ThreadPilot.Models;
using ThreadPilot.Services;

/// <summary>
/// Handles CPU Set operations for a specific process using Windows APIs
Expand Down Expand Up @@ -181,7 +182,10 @@ public double GetAverageCpuUsage()
}
}

public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false)
public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) =>
this.ApplyCpuSetMaskDetailed(affinityMask, clearMask).Success;

public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false)
{
if (this.disposed)
{
Expand All @@ -194,17 +198,21 @@ public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false)
if (this.cpuSetMapping.IsEmpty)
{
this.logger?.LogWarning("CPU Set mapping not available. Cannot apply CPU Sets to process {ProcessId}", this.pid);
return false;
return CpuSetApplyResult.Failed(
AffinityApplyErrorCodes.CpuSetsUnavailable,
ProcessOperationUserMessages.CpuSetsUnavailable,
$"CPU Set mapping is not available for process '{this.executableName}' (PID: {this.pid}).");
}

if (!this.EnsureSetHandle())
var handleResult = this.EnsureSetHandleDetailed();
if (!handleResult.Success)
{
return false;
return handleResult;
}

if (clearMask)
{
return this.ApplyCpuSetIds(null, 0, "clear CPU Set");
return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set");
}

var cpuSetIds = this.cpuSetMapping.ResolveLegacyAffinityMask(affinityMask, Environment.ProcessorCount);
Expand All @@ -214,38 +222,44 @@ public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false)
this.logger?.LogWarning(
"No valid CPU Set IDs found for affinity mask 0x{AffinityMask:X} on process '{ExecutableName}'",
affinityMask, this.executableName);
return false;
return CpuSetApplyResult.Failed(
AffinityApplyErrorCodes.InvalidTopology,
ProcessOperationUserMessages.InvalidTopology,
$"No valid CPU Set IDs found for affinity mask 0x{affinityMask:X} on process '{this.executableName}'.");
}

var cpuSetIdsArray = cpuSetIds.ToArray();
var success = this.ApplyCpuSetIds(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set");
var result = this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set");

if (success)
if (result.Success)
{
this.logger?.LogInformation(
"Applied CPU Set (affinity mask 0x{AffinityMask:X}) to '{ExecutableName}' (PID: {ProcessId})",
affinityMask, this.executableName, this.pid);
return true;
}

return false;
return result;
}

public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false)
public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false) =>
this.ApplyCpuSelectionDetailed(selection, clearSelection).Success;

public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false)
{
if (this.disposed)
{
throw new ObjectDisposedException(nameof(ProcessCpuSetHandler));
}

if (!this.EnsureSetHandle())
var handleResult = this.EnsureSetHandleDetailed();
if (!handleResult.Success)
{
return false;
return handleResult;
}

if (clearSelection)
{
return this.ApplyCpuSetIds(null, 0, "clear CPU Set selection");
return this.ApplyCpuSetIdsDetailed(null, 0, "clear CPU Set selection");
}

ArgumentNullException.ThrowIfNull(selection);
Expand All @@ -257,14 +271,22 @@ public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = fal
"No valid CPU Set IDs resolved for CPU selection on process '{ExecutableName}' (PID: {ProcessId})",
this.executableName,
this.pid);
return false;
return CpuSetApplyResult.Failed(
AffinityApplyErrorCodes.InvalidTopology,
ProcessOperationUserMessages.InvalidTopology,
$"No valid CPU Set IDs resolved for CPU selection on process '{this.executableName}' (PID: {this.pid}).");
}

var cpuSetIdsArray = cpuSetIds.ToArray();
return this.ApplyCpuSetIds(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection");
return this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection");
}

private bool EnsureSetHandle()
{
return this.EnsureSetHandleDetailed().Success;
}

private CpuSetApplyResult EnsureSetHandleDetailed()
{
if (this.setLimitedInfoHandle == null)
{
Expand All @@ -276,23 +298,35 @@ private bool EnsureSetHandle()
if (this.setLimitedInfoHandle == null || this.setLimitedInfoHandle.IsInvalid)
{
int openError = this.nativeApi.GetLastWin32Error();
string extraHelpString = (openError == 5) ? " Try restarting as Administrator" : string.Empty;
string extraHelpString = (openError == 5)
? $" {ProcessOperationUserMessages.AdminClarification}"
: 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;
return this.CreateNativeFailureResult(
"open process for CPU Set changes",
openError);
}
}
else if (this.setLimitedInfoHandle.IsInvalid)
{
// The handle was already made previously and failed, don't bother trying again
return false;
return CpuSetApplyResult.Failed(
AffinityApplyErrorCodes.CpuSetsUnavailable,
Comment on lines 312 to +316
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve access-denied classification for cached invalid CPU-set handles

When opening the CPU-set handle fails with access denied, the handler instance is cached; on the next attempt this IsInvalid path returns CpuSetsUnavailable instead of AccessDenied. In the current flow this causes later retries to be treated as "CPU sets unavailable" (triggering legacy fallback) rather than consistently surfacing the permission/protection block, so repeated attempts against the same process can produce incorrect behavior and messaging.

Useful? React with 👍 / 👎.

ProcessOperationUserMessages.CpuSetsUnavailable,
$"The cached CPU Set handle for '{this.executableName}' (PID: {this.pid}) is invalid.");
}

return true;
return CpuSetApplyResult.Succeeded($"CPU Set handle is available for '{this.executableName}' (PID: {this.pid}).");
}

private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName)
{
return this.ApplyCpuSetIdsDetailed(cpuSetIds, cpuSetIdCount, operationName).Success;
}

private CpuSetApplyResult ApplyCpuSetIdsDetailed(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName)
{
bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount);
if (success)
Expand All @@ -302,18 +336,36 @@ private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operat
operationName,
this.executableName,
this.pid);
return true;
return CpuSetApplyResult.Succeeded(
$"Completed {operationName} for '{this.executableName}' (PID: {this.pid}).");
}

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)";
errorMessage += $" {ProcessOperationUserMessages.AdminClarification}";
}

this.logger?.LogWarning(errorMessage);
return false;
return this.CreateNativeFailureResult(operationName, error, errorMessage);
}

private CpuSetApplyResult CreateNativeFailureResult(
string operationName,
int win32ErrorCode,
string? technicalMessage = null)
{
var message = technicalMessage ??
$"Could not {operationName} for '{this.executableName}' (PID: {this.pid}): {new Win32Exception(win32ErrorCode).Message}";
var accessDenied = win32ErrorCode == 5;

return CpuSetApplyResult.Failed(
accessDenied ? AffinityApplyErrorCodes.AccessDenied : AffinityApplyErrorCodes.NativeApplyFailed,
accessDenied ? ProcessOperationUserMessages.AccessDenied : ProcessOperationUserMessages.CpuSetsUnavailable,
message,
win32ErrorCode,
isAccessDenied: accessDenied);
}

/// <summary>
Expand Down
Loading
Loading