From 71e2d1a708120de3db30307644b620df77b520c0 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 20 May 2026 15:53:11 +0200 Subject: [PATCH 1/2] Improve affinity errors and priority guardrails --- Platforms/Windows/CpuSetApplyResult.cs | 46 ++++++++ Platforms/Windows/IProcessCpuSetHandler.cs | 16 +++ Platforms/Windows/ProcessCpuSetHandler.cs | 98 ++++++++++++---- Services/AffinityApplyService.cs | 105 +++++++++++++----- Services/ProcessMonitorManagerService.cs | 9 +- Services/ProcessOperationUserMessages.cs | 56 ++++++++++ Services/ProcessService.cs | 41 ++++++- .../AffinityApplyServiceTests.cs | 69 ++++++++++++ .../BaseViewModelStatusTests.cs | 32 ++++++ .../ProcessCpuSetHandlerTests.cs | 46 +++++++- .../ProcessServiceTests.cs | 78 +++++++++++++ ViewModels/BaseViewModel.cs | 21 ++++ .../ProcessViewModel.Behaviors.partial.cs | 49 ++++++-- 13 files changed, 601 insertions(+), 65 deletions(-) create mode 100644 Platforms/Windows/CpuSetApplyResult.cs create mode 100644 Services/ProcessOperationUserMessages.cs create mode 100644 Tests/ThreadPilot.Core.Tests/BaseViewModelStatusTests.cs 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..f246ca2 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -586,8 +586,20 @@ private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel pr public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) { + ArgumentNullException.ThrowIfNull(process); + ProcessPriorityGuardrails.ThrowIfBlocked(priority); 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 +613,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 +670,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 +1120,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..ed834a6 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -219,6 +219,81 @@ 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_ThrowsBlockedMessageBeforeApplying() + { + var service = CreateService(CreateTemporaryDirectory()); + + try + { + var ex = await Assert.ThrowsAsync( + () => service.SetProcessPriority(CreateProcess(), ProcessPriorityClass.RealTime)); + + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, ex.Message); + } + 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() { @@ -398,6 +473,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 +483,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, From 646c04240ad09324f68c297248039642471d9e5a Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 20 May 2026 16:00:02 +0200 Subject: [PATCH 2/2] Audit blocked realtime priority changes --- Services/ProcessService.cs | 12 +++++- .../ProcessServiceTests.cs | 42 +++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index f246ca2..47ee701 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -587,7 +587,17 @@ private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel pr public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) { ArgumentNullException.ThrowIfNull(process); - ProcessPriorityGuardrails.ThrowIfBlocked(priority); + 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); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index ed834a6..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; @@ -259,16 +261,28 @@ public void PriorityGuardrails_HighPriorityReturnsUserFacingWarning() } [Fact] - public async Task SetProcessPriority_WithRealtime_ThrowsBlockedMessageBeforeApplying() + public async Task SetProcessPriority_WithRealtime_AuditsFailureAndThrowsBlockedMessage() { - var service = CreateService(CreateTemporaryDirectory()); + 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(CreateProcess(), ProcessPriorityClass.RealTime)); + () => 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 { @@ -382,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, @@ -457,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(