diff --git a/Platforms/Windows/CpuSetApplyResult.cs b/Platforms/Windows/CpuSetApplyResult.cs
new file mode 100644
index 0000000..e312fd5
--- /dev/null
+++ b/Platforms/Windows/CpuSetApplyResult.cs
@@ -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,
+ };
+ }
+}
diff --git a/Platforms/Windows/IProcessCpuSetHandler.cs b/Platforms/Windows/IProcessCpuSetHandler.cs
index 226d51a..ac06b56 100644
--- a/Platforms/Windows/IProcessCpuSetHandler.cs
+++ b/Platforms/Windows/IProcessCpuSetHandler.cs
@@ -45,6 +45,14 @@ public interface IProcessCpuSetHandler : IDisposable
/// True if the operation succeeded, false otherwise.
bool ApplyCpuSetMask(long affinityMask, bool clearMask = false);
+ ///
+ /// Applies a CPU affinity mask to the process using CPU Sets and returns detailed failure information.
+ ///
+ /// The affinity mask where each bit represents a logical processor.
+ /// If true, clears the CPU Set (allows all cores); if false, applies the mask.
+ /// Detailed CPU Set apply result.
+ CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false);
+
///
/// Applies a topology-aware CPU selection to the process using CPU Sets.
///
@@ -53,6 +61,14 @@ public interface IProcessCpuSetHandler : IDisposable
/// True if the operation succeeded, false otherwise.
bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false);
+ ///
+ /// Applies a topology-aware CPU selection to the process using CPU Sets and returns detailed failure information.
+ ///
+ /// The CPU selection to apply. Ignored and allowed to be null when is true.
+ /// If true, clears the CPU Set selection and ignores .
+ /// Detailed CPU Set apply result.
+ CpuSetApplyResult ApplyCpuSelectionDetailed(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 6b3beb6..4c4e41b 100644
--- a/Platforms/Windows/ProcessCpuSetHandler.cs
+++ b/Platforms/Windows/ProcessCpuSetHandler.cs
@@ -23,6 +23,7 @@ namespace ThreadPilot.Platforms.Windows
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using ThreadPilot.Models;
+ using ThreadPilot.Services;
///
/// Handles CPU Set operations for a specific process using Windows APIs
@@ -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)
{
@@ -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);
@@ -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);
@@ -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)
{
@@ -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,
+ 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)
@@ -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);
}
///
diff --git a/Services/AffinityApplyService.cs b/Services/AffinityApplyService.cs
index a01ea01..c122ba3 100644
--- a/Services/AffinityApplyService.cs
+++ b/Services/AffinityApplyService.cs
@@ -144,8 +144,8 @@ public static AffinityApplyResult Failed(
TechnicalMessage = technicalMessage,
IsAccessDenied = isAccessDenied,
IsAntiCheatLikely = isAntiCheatLikely,
- IsInvalidTopology = isInvalidTopology,
- IsLegacyFallbackBlocked = isLegacyFallbackBlocked,
+ IsInvalidTopology = isInvalidTopology || errorCode == AffinityApplyErrorCodes.InvalidTopology,
+ IsLegacyFallbackBlocked = isLegacyFallbackBlocked || errorCode == AffinityApplyErrorCodes.LegacyFallbackUnsafe,
};
private static string MapFailureReason(AffinityApplyFailureReason failureReason) =>
@@ -171,16 +171,16 @@ public interface IAffinityApplyService
internal sealed class CpuSelectionAffinityApplier
{
internal const string AccessDeniedUserMessage =
- "Windows denied this change. The process may require administrator rights or may be protected.";
+ ProcessOperationUserMessages.AccessDenied;
internal const string AntiCheatUserMessage =
- "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.";
+ ProcessOperationUserMessages.AntiCheatProtectedLikely;
internal const string LegacyFallbackBlockedUserMessage =
- "This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection.";
+ ProcessOperationUserMessages.LegacyFallbackBlocked;
internal const string InvalidSelectionUserMessage =
- "This CPU selection is empty or does not match the current CPU topology.";
+ ProcessOperationUserMessages.InvalidTopology;
private readonly Func cpuSetHandlerFactory;
private readonly Func> legacyAffinityApplier;
@@ -216,6 +216,7 @@ public async Task ApplyAsync(ProcessModel process, CpuSelec
AffinityApplyErrorCodes.InvalidSelection,
InvalidSelectionUserMessage,
"CpuSelection contains neither CPU Set IDs nor logical processors.",
+ isInvalidTopology: true,
failureReason: AffinityApplyFailureReason.InvalidMask);
}
@@ -282,16 +283,48 @@ public async Task ApplyAsync(ProcessModel process, CpuSelec
return null;
}
- if (handler.ApplyCpuSelection(selection))
+ var result = handler.ApplyCpuSelectionDetailed(selection);
+ if (result.Success)
{
this.Audit(process, success: true);
return AffinityApplyResult.SucceededWithCpuSets(
- $"CPU Sets applied to process {process.Name} (PID: {process.ProcessId}).");
+ string.IsNullOrWhiteSpace(result.TechnicalMessage)
+ ? $"CPU Sets applied to process {process.Name} (PID: {process.ProcessId})."
+ : result.TechnicalMessage);
}
- // ProcessCpuSetHandler.ApplyCpuSelection currently returns only bool. A false
- // can mean CPU Sets unavailable, access denied without detailed error, or
- // a native apply failure. Later UX/error-model work will classify this more precisely.
+ if (result.IsAccessDenied || result.ErrorCode == AffinityApplyErrorCodes.AccessDenied)
+ {
+ this.Audit(process, success: false);
+ return AffinityApplyResult.Failed(
+ result.IsAntiCheatLikely
+ ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely
+ : AffinityApplyErrorCodes.AccessDenied,
+ result.IsAntiCheatLikely ? AntiCheatUserMessage : AccessDeniedUserMessage,
+ result.TechnicalMessage,
+ isAccessDenied: true,
+ isAntiCheatLikely: result.IsAntiCheatLikely,
+ verifiedMask: process.ProcessorAffinity,
+ failureReason: AffinityApplyFailureReason.AccessDenied);
+ }
+
+ if (result.ErrorCode == AffinityApplyErrorCodes.InvalidTopology)
+ {
+ this.Audit(process, success: false);
+ return AffinityApplyResult.Failed(
+ AffinityApplyErrorCodes.InvalidTopology,
+ ProcessOperationUserMessages.InvalidTopology,
+ result.TechnicalMessage,
+ isInvalidTopology: true,
+ verifiedMask: process.ProcessorAffinity,
+ failureReason: AffinityApplyFailureReason.InvalidMask);
+ }
+
+ this.logger.LogDebug(
+ "CPU Sets unavailable for process {ProcessName} (PID: {ProcessId}): {Message}",
+ process.Name,
+ process.ProcessId,
+ result.TechnicalMessage);
return null;
}
catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex))
@@ -337,7 +370,7 @@ private static AffinityApplyResult AccessDenied(Exception ex, long requestedMask
private static AffinityApplyResult ProcessExited(string userMessage, ProcessModel? process, long requestedMask = 0) =>
AffinityApplyResult.Failed(
AffinityApplyErrorCodes.ProcessExited,
- userMessage,
+ ProcessOperationUserMessages.ProcessExited,
userMessage,
requestedMask: requestedMask,
verifiedMask: process?.ProcessorAffinity ?? 0,
@@ -408,7 +441,7 @@ public Task ApplyAsync(ProcessModel process, CpuSelection s
process == null
? Task.FromResult(AffinityApplyResult.Failed(
AffinityApplyErrorCodes.ProcessExited,
- "Process is no longer running.",
+ ProcessOperationUserMessages.ProcessExited,
"ProcessModel is null.",
failureReason: AffinityApplyFailureReason.ProcessTerminated))
: selection == null
@@ -416,6 +449,7 @@ public Task ApplyAsync(ProcessModel process, CpuSelection s
AffinityApplyErrorCodes.InvalidSelection,
CpuSelectionAffinityApplier.InvalidSelectionUserMessage,
"CpuSelection is null.",
+ isInvalidTopology: true,
failureReason: AffinityApplyFailureReason.InvalidMask))
: this.processService.SetProcessorAffinity(process, selection);
@@ -431,16 +465,19 @@ public async Task ApplyAsync(ProcessModel process, long req
requestedMask,
startingMask,
AffinityApplyFailureReason.InvalidMask,
- "Affinity mask cannot be zero.");
+ ProcessOperationUserMessages.InvalidTopology);
}
if (!this.cpuTopologyService.IsAffinityMaskValid(requestedMask))
{
return AffinityApplyResult.Failed(
- requestedMask,
- startingMask,
- AffinityApplyFailureReason.InvalidMask,
- "Affinity mask is not valid for this CPU topology.");
+ AffinityApplyErrorCodes.InvalidTopology,
+ ProcessOperationUserMessages.InvalidTopology,
+ $"Affinity mask 0x{requestedMask:X} is not valid for this CPU topology.",
+ isInvalidTopology: true,
+ requestedMask: requestedMask,
+ verifiedMask: startingMask,
+ failureReason: AffinityApplyFailureReason.InvalidMask);
}
if (!await this.IsProcessRunningAsync(process).ConfigureAwait(false))
@@ -449,7 +486,7 @@ public async Task ApplyAsync(ProcessModel process, long req
requestedMask,
startingMask,
AffinityApplyFailureReason.ProcessTerminated,
- "Process is no longer running.");
+ ProcessOperationUserMessages.ProcessExited);
}
try
@@ -465,11 +502,7 @@ public async Task ApplyAsync(ProcessModel process, long req
process.ProcessId);
await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false);
- return AffinityApplyResult.Failed(
- requestedMask,
- process.ProcessorAffinity,
- AffinityApplyFailureReason.AccessDenied,
- "Affinity change blocked (anti-cheat or insufficient privileges).");
+ return AccessDenied(ex, requestedMask, process.ProcessorAffinity);
}
catch (Exception ex) when (IsProcessTerminated(ex))
{
@@ -483,7 +516,7 @@ public async Task ApplyAsync(ProcessModel process, long req
requestedMask,
process.ProcessorAffinity,
AffinityApplyFailureReason.ProcessTerminated,
- "Process exited before affinity could be applied.");
+ ProcessOperationUserMessages.ProcessExited);
}
catch (Exception ex)
{
@@ -498,7 +531,7 @@ public async Task ApplyAsync(ProcessModel process, long req
requestedMask,
process.ProcessorAffinity,
AffinityApplyFailureReason.ApplyFailed,
- $"Failed to set processor affinity: {ex.Message}");
+ "ThreadPilot could not apply this affinity change.");
}
if (!await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false))
@@ -507,7 +540,7 @@ public async Task ApplyAsync(ProcessModel process, long req
requestedMask,
process.ProcessorAffinity,
AffinityApplyFailureReason.ProcessTerminated,
- "Process exited before affinity could be verified.");
+ ProcessOperationUserMessages.ProcessExited);
}
var verifiedMask = process.ProcessorAffinity;
@@ -523,6 +556,24 @@ public async Task ApplyAsync(ProcessModel process, long req
return AffinityApplyResult.Succeeded(requestedMask, verifiedMask);
}
+ private static AffinityApplyResult AccessDenied(Exception ex, long requestedMask, long verifiedMask)
+ {
+ var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex);
+ return AffinityApplyResult.Failed(
+ antiCheatLikely
+ ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely
+ : AffinityApplyErrorCodes.AccessDenied,
+ antiCheatLikely
+ ? ProcessOperationUserMessages.AntiCheatProtectedLikely
+ : ProcessOperationUserMessages.AccessDenied,
+ ex.Message,
+ isAccessDenied: true,
+ isAntiCheatLikely: antiCheatLikely,
+ requestedMask: requestedMask,
+ verifiedMask: verifiedMask,
+ failureReason: AffinityApplyFailureReason.AccessDenied);
+ }
+
private static bool IsAccessDenied(Exception ex)
{
var message = ex.Message ?? string.Empty;
diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs
index 6311a78..9b6e9e1 100644
--- a/Services/ProcessMonitorManagerService.cs
+++ b/Services/ProcessMonitorManagerService.cs
@@ -791,7 +791,14 @@ private static string BuildAffinityOrPriorityBlockedMessage(Exception ex, string
lowered.Contains("insufficient privileges") ||
ex is UnauthorizedAccessException)
{
- return $"{operation} change blocked by Anti-Cheat/System for '{processName}'.";
+ return lowered.Contains("anti-cheat") || lowered.Contains("anti cheat") || lowered.Contains("protected")
+ ? ProcessOperationUserMessages.AntiCheatProtectedLikely
+ : ProcessOperationUserMessages.AccessDenied;
+ }
+
+ if (lowered.Contains("realtime priority is blocked"))
+ {
+ return ProcessOperationUserMessages.RealtimePriorityBlocked;
}
return string.Empty;
diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs
new file mode 100644
index 0000000..78863c1
--- /dev/null
+++ b/Services/ProcessOperationUserMessages.cs
@@ -0,0 +1,56 @@
+namespace ThreadPilot.Services
+{
+ using System.Diagnostics;
+
+ internal static class ProcessOperationUserMessages
+ {
+ public const string AccessDenied =
+ "Windows denied this change. The process may require administrator rights or may be protected.";
+
+ public const string AntiCheatProtectedLikely =
+ "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.";
+
+ public const string AdminClarification =
+ "Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions.";
+
+ public const string LegacyFallbackBlocked =
+ "This CPU selection cannot be safely represented by legacy affinity APIs on this topology. CPU Sets are required for this selection.";
+
+ public const string InvalidTopology =
+ "This CPU selection does not match the current CPU topology. Review or recreate the preset.";
+
+ public const string ProcessExited =
+ "The process exited before ThreadPilot could apply the change.";
+
+ public const string CpuSetsUnavailable =
+ "Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible.";
+
+ public const string HighPriorityWarning =
+ "High priority can improve responsiveness for some workloads but may reduce system responsiveness.";
+
+ public const string RealtimePriorityBlocked =
+ "Realtime priority is blocked by ThreadPilot because it can make Windows unstable or unresponsive.";
+
+ public const string PersistentLaunchTimePriorityNotice =
+ "Persistent launch-time priority may be supported for normal processes, but it does not bypass protected process or anti-cheat restrictions.";
+ }
+
+ internal static class ProcessPriorityGuardrails
+ {
+ public static string? GetWarning(ProcessPriorityClass priority) =>
+ priority == ProcessPriorityClass.High
+ ? ProcessOperationUserMessages.HighPriorityWarning
+ : null;
+
+ public static bool IsBlocked(ProcessPriorityClass priority) =>
+ priority == ProcessPriorityClass.RealTime;
+
+ public static void ThrowIfBlocked(ProcessPriorityClass priority)
+ {
+ if (IsBlocked(priority))
+ {
+ throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked);
+ }
+ }
+ }
+}
diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs
index c6c14a4..47ee701 100644
--- a/Services/ProcessService.cs
+++ b/Services/ProcessService.cs
@@ -586,8 +586,30 @@ private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel pr
public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority)
{
+ ArgumentNullException.ThrowIfNull(process);
+ if (ProcessPriorityGuardrails.IsBlocked(priority))
+ {
+ this.logger?.LogWarning(
+ "Blocked priority change for process {ProcessName} (PID: {ProcessId}): {Message}",
+ process.Name,
+ process.ProcessId,
+ ProcessOperationUserMessages.RealtimePriorityBlocked);
+ this.AuditProcessOperation("SetProcessPriority", process.Name, success: false);
+ throw new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked);
+ }
+
this.EnsureProcessOperationAllowed(process, "SetProcessPriority");
+ var warning = ProcessPriorityGuardrails.GetWarning(priority);
+ if (!string.IsNullOrWhiteSpace(warning))
+ {
+ this.logger?.LogWarning(
+ "Applying High priority to process {ProcessName} (PID: {ProcessId}): {Message}",
+ process.Name,
+ process.ProcessId,
+ warning);
+ }
+
await Task.Run(() =>
{
try
@@ -601,12 +623,12 @@ await Task.Run(() =>
catch (Win32Exception ex) when (ex.NativeErrorCode == 5)
{
this.AuditProcessOperation("SetProcessPriority", process.Name, success: false);
- throw new InvalidOperationException("Access denied while setting process priority. The process may be protected (e.g., anti-cheat).", ex);
+ throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex);
}
catch (UnauthorizedAccessException ex)
{
this.AuditProcessOperation("SetProcessPriority", process.Name, success: false);
- throw new InvalidOperationException("Access denied while setting process priority. The process may be protected (e.g., anti-cheat).", ex);
+ throw new InvalidOperationException(ProcessOperationUserMessages.AccessDenied, ex);
}
catch (Exception ex)
{
@@ -658,6 +680,18 @@ public async Task LoadProcessProfile(string profileName, ProcessModel proc
return false;
}
+ if (ProcessPriorityGuardrails.IsBlocked(profile.Priority))
+ {
+ this.logger?.LogWarning(
+ "Profile {ProfileName} requested blocked priority {Priority} for process {ProcessName} (PID: {ProcessId}). {Message}",
+ profileName,
+ profile.Priority,
+ process.Name,
+ process.ProcessId,
+ ProcessOperationUserMessages.RealtimePriorityBlocked);
+ return false;
+ }
+
await this.SetLoadProcessProfilePriorityAsync(process, profile.Priority).ConfigureAwait(false);
var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false);
if (topology != null)
@@ -1096,6 +1130,19 @@ public async Task SetIdleServerStateAsync(ProcessModel process, bool enabl
public async Task SetRegistryPriorityAsync(ProcessModel process, bool enable, ProcessPriorityClass priority)
{
+ ArgumentNullException.ThrowIfNull(process);
+
+ if (enable && ProcessPriorityGuardrails.IsBlocked(priority))
+ {
+ this.logger?.LogWarning(
+ "Registry priority request blocked for process {ProcessName} (PID: {ProcessId}). {Message}",
+ process.Name,
+ process.ProcessId,
+ ProcessOperationUserMessages.RealtimePriorityBlocked);
+ this.AuditProcessOperation("SetProcessPriority", process.Name, success: false);
+ return false;
+ }
+
this.EnsureProcessOperationAllowed(process, "SetProcessPriority");
return await Task.Run(() =>
diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs
index 32c6893..bd1eabe 100644
--- a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs
@@ -43,6 +43,7 @@ public async Task ApplyAsync_WhenProcessIsTerminated_ReturnsFailureWithoutApplyi
Assert.False(result.Success);
Assert.Equal(AffinityApplyFailureReason.ProcessTerminated, result.FailureReason);
+ Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage);
processService.Verify(
service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
Times.Never);
@@ -63,9 +64,35 @@ public async Task ApplyAsync_WhenAccessDenied_ReturnsAccessDeniedFailure()
Assert.False(result.Success);
Assert.Equal(AffinityApplyFailureReason.AccessDenied, result.FailureReason);
+ Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage);
+ Assert.True(result.IsAccessDenied);
+ Assert.False(result.UserMessage.Contains("bypass", StringComparison.OrdinalIgnoreCase));
Assert.Equal(3, result.VerifiedMask);
}
+ [Fact]
+ public async Task ApplyAsync_WhenAntiCheatProtected_ReturnsProtectedMessageWithoutBypassSuggestion()
+ {
+ var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 };
+ var processService = CreateProcessService(processStillRunning: true);
+ processService
+ .Setup(service => service.SetProcessorAffinity(process, 1))
+ .ThrowsAsync(new InvalidOperationException("Protected by anti-cheat."));
+
+ var service = CreateService(processService);
+
+ var result = await service.ApplyAsync(process, 1);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage);
+ Assert.True(result.IsAccessDenied);
+ Assert.True(result.IsAntiCheatLikely);
+ Assert.Contains("will not try to bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public async Task ApplyAsync_WhenVerifiedMaskDiffers_ReturnsMismatchFailure()
{
@@ -100,6 +127,7 @@ public async Task ApplyAsync_WhenMaskIsZero_ReturnsInvalidMaskFailure()
Assert.False(result.Success);
Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason);
+ Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage);
}
[Fact]
@@ -115,11 +143,21 @@ public async Task ApplyAsync_WhenTopologyRejectsMask_ReturnsInvalidMaskFailure()
Assert.False(result.Success);
Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason);
+ Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode);
+ Assert.True(result.IsInvalidTopology);
+ Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage);
processService.Verify(
service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
Times.Never);
}
+ [Fact]
+ public void AdminClarification_DoesNotPromiseAntiCheatBypass()
+ {
+ Assert.Contains("Administrator mode may help", ProcessOperationUserMessages.AdminClarification);
+ Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification);
+ }
+
[Fact]
public async Task ApplyAsync_WhenProcessStateCheckIsAccessDenied_StillAttemptsApply()
{
@@ -221,6 +259,7 @@ public async Task CpuSelectionApply_WhenCpuSetsFailAndSelectionHasMultipleGroups
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.LegacyFallbackUnsafe, result.ErrorCode);
Assert.True(result.IsLegacyFallbackBlocked);
+ Assert.Equal(ProcessOperationUserMessages.LegacyFallbackBlocked, result.UserMessage);
Assert.False(result.UsedLegacyAffinity);
Assert.Equal(0, legacy.CallCount);
}
@@ -291,6 +330,8 @@ public async Task CpuSelectionApply_WhenSelectionIsEmpty_ReturnsInvalidSelection
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage);
+ Assert.True(result.IsInvalidTopology);
Assert.False(result.UsedCpuSets);
Assert.False(result.UsedLegacyAffinity);
Assert.Equal(0, cpuSets.ApplyCpuSelectionCalls);
@@ -365,6 +406,7 @@ public async Task CpuSelectionApply_WhenCpuSetsThrowAccessDenied_ReturnsAccessDe
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage);
Assert.True(result.IsAccessDenied);
Assert.Equal(0, legacy.CallCount);
}
@@ -405,6 +447,7 @@ public async Task CpuSelectionApply_WhenFallbackThrowsProcessExited_ReturnsProce
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage);
Assert.False(result.UsedLegacyAffinity);
}
@@ -418,6 +461,7 @@ public async Task ApplyCpuSelectionAsync_WhenProcessIsNull_ReturnsProcessExitedW
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage);
processService.Verify(
service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
Times.Never);
@@ -434,6 +478,7 @@ public async Task ApplyCpuSelectionAsync_WhenSelectionIsNull_ReturnsInvalidSelec
Assert.False(result.Success);
Assert.Equal(AffinityApplyErrorCodes.InvalidSelection, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage);
processService.Verify(
service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()),
Times.Never);
@@ -533,6 +578,12 @@ private sealed class FakeCpuSetHandler : IProcessCpuSetHandler
public bool ApplyCpuSetMask(long affinityMask, bool clearMask = false) => false;
+ public CpuSetApplyResult ApplyCpuSetMaskDetailed(long affinityMask, bool clearMask = false) =>
+ CpuSetApplyResult.Failed(
+ AffinityApplyErrorCodes.CpuSetsUnavailable,
+ ProcessOperationUserMessages.CpuSetsUnavailable,
+ "Fake CPU Sets handler rejected the legacy mask.");
+
public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = false)
{
this.ApplyCpuSelectionCalls++;
@@ -546,6 +597,24 @@ public bool ApplyCpuSelection(CpuSelection? selection, bool clearSelection = fal
return this.ApplyCpuSelectionResult;
}
+ public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool clearSelection = false)
+ {
+ this.ApplyCpuSelectionCalls++;
+ this.LastSelection = selection;
+
+ if (this.ApplyCpuSelectionException != null)
+ {
+ throw this.ApplyCpuSelectionException;
+ }
+
+ return this.ApplyCpuSelectionResult
+ ? CpuSetApplyResult.Succeeded("Fake CPU Sets handler applied the selection.")
+ : CpuSetApplyResult.Failed(
+ AffinityApplyErrorCodes.CpuSetsUnavailable,
+ ProcessOperationUserMessages.CpuSetsUnavailable,
+ "Fake CPU Sets handler rejected the selection.");
+ }
+
public double GetAverageCpuUsage() => 0;
public void Dispose()
diff --git a/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs b/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs
new file mode 100644
index 0000000..cc923af
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs
@@ -0,0 +1,32 @@
+namespace ThreadPilot.Core.Tests
+{
+ using Microsoft.Extensions.Logging.Abstractions;
+ using ThreadPilot.ViewModels;
+
+ public sealed class BaseViewModelStatusTests
+ {
+ [Fact]
+ public void ClearStatus_DoesNotClearCriticalStatus()
+ {
+ var viewModel = new TestViewModel();
+
+ viewModel.SetCritical("Realtime priority is blocked.");
+ viewModel.Clear();
+
+ Assert.Equal("Realtime priority is blocked.", viewModel.StatusMessage);
+ Assert.False(viewModel.IsBusy);
+ }
+
+ private sealed class TestViewModel : BaseViewModel
+ {
+ public TestViewModel()
+ : base(NullLogger.Instance)
+ {
+ }
+
+ public void SetCritical(string message) => this.SetCriticalStatus(message);
+
+ public void Clear() => this.ClearStatus();
+ }
+ }
+}
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs
index 54f8bb8..891b6ea 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs
@@ -4,6 +4,7 @@ namespace ThreadPilot.Core.Tests
using Microsoft.Win32.SafeHandles;
using ThreadPilot.Models;
using ThreadPilot.Platforms.Windows;
+ using ThreadPilot.Services;
public sealed class ProcessCpuSetHandlerTests
{
@@ -194,6 +195,47 @@ public void ProcessCpuSetHandler_ApplyCpuSelection_WithoutResolvableCpuSets_Retu
Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled);
}
+ [Fact]
+ public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WithoutResolvableCpuSets_ReturnsInvalidTopology()
+ {
+ var nativeApi = new FakeProcessCpuSetNativeApi();
+ using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty);
+ var selection = new CpuSelection
+ {
+ LogicalProcessors = [new ProcessorRef(1, 0, 64)],
+ };
+
+ var result = handler.ApplyCpuSelectionDetailed(selection);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyErrorCodes.InvalidTopology, result.ErrorCode);
+ Assert.Equal(ProcessOperationUserMessages.InvalidTopology, result.UserMessage);
+ Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled);
+ }
+
+ [Fact]
+ public void ProcessCpuSetHandler_ApplyCpuSelectionDetailed_WhenNativeAccessDenied_ReturnsAccessDenied()
+ {
+ var nativeApi = new FakeProcessCpuSetNativeApi
+ {
+ SetProcessDefaultCpuSetsResult = false,
+ LastWin32Error = 5,
+ };
+ using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty);
+ var selection = new CpuSelection
+ {
+ CpuSetIds = [400],
+ };
+
+ var result = handler.ApplyCpuSelectionDetailed(selection);
+
+ Assert.False(result.Success);
+ Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode);
+ Assert.Equal(5, result.Win32ErrorCode);
+ Assert.True(result.IsAccessDenied);
+ Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage);
+ }
+
[Fact]
public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacySingleGroupMappingIsPreserved()
{
@@ -248,6 +290,8 @@ private sealed class FakeProcessCpuSetNativeApi : IProcessCpuSetNativeApi
public int LastWin32Error { get; set; }
+ public bool SetProcessDefaultCpuSetsResult { get; init; } = true;
+
public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId)
{
return new SafeProcessHandle(new IntPtr(1), ownsHandle: false);
@@ -258,7 +302,7 @@ public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetId
this.WasSetProcessDefaultCpuSetsCalled = true;
this.LastAppliedCpuSetIds = cpuSetIds;
this.LastAppliedCpuSetCount = cpuSetIdCount;
- return true;
+ return this.SetProcessDefaultCpuSetsResult;
}
public bool GetProcessTimes(
diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
index dcc0b57..8bc5783 100644
--- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
+++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs
@@ -7,6 +7,8 @@ namespace ThreadPilot.Core.Tests
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
+ using Microsoft.Extensions.Logging;
+ using Moq;
using ThreadPilot.Models;
using ThreadPilot.Services;
@@ -219,6 +221,93 @@ public async Task LoadProcessProfile_WithoutTopologyProvider_UsesLegacyAffinityP
}
}
+ [Fact]
+ public async Task LoadProcessProfile_WithRealtimePriority_ReturnsFalseWithoutApplyingPriorityOrAffinity()
+ {
+ var profilesDirectory = CreateTemporaryDirectory();
+ var profileName = $"profile-{Guid.NewGuid():N}";
+ var profile = new ProcessProfileSnapshot
+ {
+ ProcessName = "game.exe",
+ Priority = ProcessPriorityClass.RealTime,
+ ProcessorAffinity = 0b11,
+ };
+ var profileApplier = new FakeLoadProcessProfileApplier();
+ var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier);
+
+ try
+ {
+ await WriteProfileAsync(profilesDirectory, profileName, profile);
+
+ var result = await service.LoadProcessProfile(profileName, CreateProcess());
+
+ Assert.False(result);
+ Assert.Equal(0, profileApplier.PriorityApplyCalls);
+ Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls);
+ Assert.Equal(0, profileApplier.CpuSelectionApplyCalls);
+ }
+ finally
+ {
+ DeleteDirectory(profilesDirectory);
+ }
+ }
+
+ [Fact]
+ public void PriorityGuardrails_HighPriorityReturnsUserFacingWarning()
+ {
+ var warning = ProcessPriorityGuardrails.GetWarning(ProcessPriorityClass.High);
+
+ Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, warning);
+ }
+
+ [Fact]
+ public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedMessage()
+ {
+ var logger = new Mock>();
+ var security = new Mock(MockBehavior.Strict);
+ security
+ .Setup(s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false))
+ .Returns(Task.CompletedTask);
+
+ var service = CreateService(CreateTemporaryDirectory(), logger: logger.Object, securityService: security.Object);
+ var process = CreateProcess();
+
+ try
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => service.SetProcessPriority(process, ProcessPriorityClass.RealTime));
+
+ Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, ex.Message);
+ Assert.Equal(ProcessPriorityClass.Normal, process.Priority);
+ security.Verify(
+ s => s.AuditElevatedAction("SetProcessPriority", "game.exe", false),
+ Times.Once);
+ VerifyWarningLogged(logger, ProcessOperationUserMessages.RealtimePriorityBlocked);
+ }
+ finally
+ {
+ DeleteDirectory(GetProfilesDirectory(service));
+ }
+ }
+
+ [Fact]
+ public async Task SetRegistryPriorityAsync_WithRealtime_ReturnsFalse()
+ {
+ var service = CreateService(CreateTemporaryDirectory());
+
+ try
+ {
+ var result = await service.SetRegistryPriorityAsync(CreateProcess(), enable: true, ProcessPriorityClass.RealTime);
+
+ Assert.False(result);
+ Assert.Contains("does not bypass", ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ DeleteDirectory(GetProfilesDirectory(service));
+ }
+ }
+
[Fact]
public void IsPassiveProcessAccessException_ReturnsTrue_ForModuleEnumerationFailure()
{
@@ -307,16 +396,18 @@ public void UntrackProcess_ClearsTrackedState()
private static ProcessService CreateService(
string profilesDirectory,
ICpuTopologyProvider? topologyProvider = null,
- FakeLoadProcessProfileApplier? profileApplier = null)
+ FakeLoadProcessProfileApplier? profileApplier = null,
+ ILogger? logger = null,
+ ISecurityService? securityService = null)
{
if (profileApplier == null)
{
- return new(null, null, () => profilesDirectory, cpuTopologyProvider: topologyProvider);
+ return new(logger, securityService, () => profilesDirectory, cpuTopologyProvider: topologyProvider);
}
return new ProcessService(
- null,
- null,
+ logger,
+ securityService,
() => profilesDirectory,
foregroundProcessService: null,
processClassifier: null,
@@ -382,6 +473,18 @@ private static ConcurrentDictionary GetPrivateDictionary)(field?.GetValue(service) ?? throw new InvalidOperationException($"Field '{fieldName}' not found."));
}
+ private static void VerifyWarningLogged(Mock> logger, string message)
+ {
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny(),
+ It.Is((state, _) => state.ToString() != null && state.ToString()!.Contains(message, StringComparison.Ordinal)),
+ It.IsAny(),
+ It.IsAny>()),
+ Times.Once);
+ }
+
private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider
{
public Task GetTopologySnapshotAsync(
@@ -398,6 +501,8 @@ public FakeLoadProcessProfileApplier(AffinityApplyResult? cpuSelectionResult = n
this.cpuSelectionResult = cpuSelectionResult ?? AffinityApplyResult.Succeeded(0, 0);
}
+ public int PriorityApplyCalls { get; private set; }
+
public int CpuSelectionApplyCalls { get; private set; }
public int LegacyAffinityApplyCalls { get; private set; }
@@ -406,6 +511,7 @@ public FakeLoadProcessProfileApplier(AffinityApplyResult? cpuSelectionResult = n
public Task SetPriorityAsync(ProcessModel process, ProcessPriorityClass priority)
{
+ this.PriorityApplyCalls++;
process.Priority = priority;
return Task.CompletedTask;
}
diff --git a/ViewModels/BaseViewModel.cs b/ViewModels/BaseViewModel.cs
index 2d51a3b..0df9a8f 100644
--- a/ViewModels/BaseViewModel.cs
+++ b/ViewModels/BaseViewModel.cs
@@ -32,6 +32,7 @@ public abstract partial class BaseViewModel : ObservableObject, IDisposable
protected readonly IEnhancedLoggingService? EnhancedLoggingService;
private bool disposed;
private CancellationTokenSource? statusLifetimeCts;
+ private bool preserveStatusUntilReplaced;
private const int StatusVisibleDurationMs = 1500;
private const int StatusFadeDurationMs = 500;
@@ -60,8 +61,22 @@ protected BaseViewModel(ILogger logger, IEnhancedLoggingService? enhancedLogging
/// Set status message and busy state.
///
protected void SetStatus(string message, bool isBusyState = true)
+ {
+ this.SetStatus(message, isBusyState, preserveUntilReplaced: false);
+ }
+
+ ///
+ /// Set a critical status that should not be cleared by immediate cleanup paths.
+ ///
+ protected void SetCriticalStatus(string message)
+ {
+ this.SetStatus(message, isBusyState: false, preserveUntilReplaced: true);
+ }
+
+ private void SetStatus(string message, bool isBusyState, bool preserveUntilReplaced)
{
this.CancelStatusLifetime();
+ this.preserveStatusUntilReplaced = preserveUntilReplaced;
this.StatusOpacity = 1.0;
this.StatusMessage = message;
this.IsBusy = isBusyState;
@@ -78,6 +93,12 @@ protected void SetStatus(string message, bool isBusyState = true)
///
protected void ClearStatus()
{
+ if (this.preserveStatusUntilReplaced)
+ {
+ this.IsBusy = false;
+ return;
+ }
+
this.CancelStatusLifetime();
this.StatusMessage = string.Empty;
this.StatusOpacity = 1.0;
diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs
index 7fd87c4..47f1f33 100644
--- a/ViewModels/ProcessViewModel.Behaviors.partial.cs
+++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs
@@ -730,12 +730,17 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
this.SelectedProcess = null;
this.ClearProcessSelection();
- this.SetStatus(result.Message, false);
+ this.SetCriticalStatus(result.Message);
_ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning);
}
else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied)
{
- this.SetStatus(result.Message, false);
+ this.SetCriticalStatus(result.Message);
+ _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning);
+ }
+ else if (result.IsInvalidTopology || result.IsLegacyFallbackBlocked)
+ {
+ this.SetCriticalStatus(result.Message);
_ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning);
}
else
@@ -752,7 +757,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Error setting affinity: {friendly}", false);
+ this.SetCriticalStatus($"Error setting affinity: {friendly}");
});
// Try to refresh process info even if setting failed, to show current state
@@ -1141,8 +1146,17 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
// Verify the priority was set correctly
if (this.SelectedProcess.Priority == priority)
{
- this.SetStatus($"Priority applied successfully to {this.SelectedProcess.Name}: {priority}.", false);
- _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{this.SelectedProcess.Name}: {priority}", NotificationType.Success);
+ var warning = ProcessPriorityGuardrails.GetWarning(priority);
+ if (!string.IsNullOrWhiteSpace(warning))
+ {
+ this.SetCriticalStatus(warning);
+ _ = this.notificationService.ShowNotificationAsync("Priority warning", warning, NotificationType.Warning);
+ }
+ else
+ {
+ this.SetStatus($"Priority applied successfully to {this.SelectedProcess.Name}: {priority}.", false);
+ _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{this.SelectedProcess.Name}: {priority}", NotificationType.Success);
+ }
}
else
{
@@ -1154,10 +1168,15 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
catch (Exception ex)
{
var message = ex.Message;
- if (message.Contains("Access denied", StringComparison.OrdinalIgnoreCase) ||
+ if (message.Contains("Realtime priority is blocked", StringComparison.OrdinalIgnoreCase))
+ {
+ message = ProcessOperationUserMessages.RealtimePriorityBlocked;
+ _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning);
+ }
+ else if (message.Contains("Access denied", StringComparison.OrdinalIgnoreCase) ||
message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase))
{
- message = "Priority change blocked (anti-cheat or insufficient privileges).";
+ message = ProcessOperationUserMessages.AccessDenied;
_ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning);
}
else
@@ -1167,7 +1186,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Error setting priority: {message}", false);
+ this.SetCriticalStatus($"Error setting priority: {message}");
});
// Try to refresh process info even if setting failed, to show current state
@@ -1233,17 +1252,24 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
this.SetStatus($"Loading profile {this.ProfileName}...");
});
- await this.processService.LoadProcessProfile(this.ProfileName, this.SelectedProcess);
+ var success = await this.processService.LoadProcessProfile(this.ProfileName, this.SelectedProcess);
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.ClearStatus();
+ if (success)
+ {
+ this.ClearStatus();
+ }
+ else
+ {
+ this.SetCriticalStatus($"Profile {this.ProfileName} could not be fully applied.");
+ }
});
}
catch (Exception ex)
{
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
- this.SetStatus($"Error loading profile: {ex.Message}", false);
+ this.SetCriticalStatus($"Error loading profile: {ex.Message}");
});
}
}
@@ -1600,6 +1626,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
System.Windows.MessageBox.Show(
$"Registry priority has been set for {this.SelectedProcess.Name}.\n\n" +
"The process must be restarted for the registry changes to take effect.\n\n" +
+ $"{ProcessOperationUserMessages.PersistentLaunchTimePriorityNotice}\n\n" +
"This setting will persist across system reboots and will automatically apply the selected priority when the process starts.",
"Registry Priority Set - Restart Required",
System.Windows.MessageBoxButton.OK,