From e21dcc0fe73d73866a0dc046ba467aa642bcf9af Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 20 May 2026 21:35:16 +0200 Subject: [PATCH 1/2] Add process memory priority foundation --- Models/PersistentProcessRule.cs | 6 + Models/ProcessMemoryPriority.cs | 19 ++ Platforms/Windows/CpuSetNativeMethods.cs | 1 + .../IProcessMemoryPriorityNativeApi.cs | 88 +++++++ .../ProcessMemoryPriorityNativeMethods.cs | 33 +++ Services/IProcessMemoryPriorityService.cs | 14 + Services/PersistentRulesEngine.cs | 62 ++++- Services/ProcessMemoryPriorityService.cs | 239 ++++++++++++++++++ Services/ProcessOperationResult.cs | 48 ++++ Services/ProcessOperationUserMessages.cs | 2 +- Services/ServiceConfiguration.cs | 3 + .../AffinityApplyServiceTests.cs | 2 +- .../PersistentProcessRuleJsonStoreTests.cs | 5 + .../PersistentRulesEngineTests.cs | 143 ++++++++++- .../ProcessMemoryPriorityServiceTests.cs | 227 +++++++++++++++++ 15 files changed, 875 insertions(+), 17 deletions(-) create mode 100644 Models/ProcessMemoryPriority.cs create mode 100644 Platforms/Windows/IProcessMemoryPriorityNativeApi.cs create mode 100644 Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs create mode 100644 Services/IProcessMemoryPriorityService.cs create mode 100644 Services/ProcessMemoryPriorityService.cs create mode 100644 Services/ProcessOperationResult.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs index a8ec47a..c56048c 100644 --- a/Models/PersistentProcessRule.cs +++ b/Models/PersistentProcessRule.cs @@ -24,10 +24,14 @@ public sealed record PersistentProcessRule public ProcessPriorityClass? Priority { get; init; } + public ProcessMemoryPriority? MemoryPriority { get; init; } + public bool ApplyAffinityOnStart { get; init; } public bool ApplyPriorityOnStart { get; init; } + public bool ApplyMemoryPriorityOnStart { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime UpdatedAt { get; init; } = DateTime.UtcNow; @@ -49,6 +53,8 @@ public sealed record PersistentRuleApplyResult public bool PriorityApplied { get; init; } + public bool MemoryPriorityApplied { get; init; } + public string? ErrorCode { get; init; } public string UserMessage { get; init; } = string.Empty; diff --git a/Models/ProcessMemoryPriority.cs b/Models/ProcessMemoryPriority.cs new file mode 100644 index 0000000..7306868 --- /dev/null +++ b/Models/ProcessMemoryPriority.cs @@ -0,0 +1,19 @@ +/* + * ThreadPilot - process memory priority model. + */ +namespace ThreadPilot.Models +{ + /// + /// Documented Windows process memory priority levels. + /// CPU priority influences CPU scheduling; memory priority influences how aggressively + /// Windows may reclaim or page a process's memory under pressure. + /// + public enum ProcessMemoryPriority + { + VeryLow = 1, + Low = 2, + Medium = 3, + BelowNormal = 4, + Normal = 5, + } +} diff --git a/Platforms/Windows/CpuSetNativeMethods.cs b/Platforms/Windows/CpuSetNativeMethods.cs index 98b6fab..06ee816 100644 --- a/Platforms/Windows/CpuSetNativeMethods.cs +++ b/Platforms/Windows/CpuSetNativeMethods.cs @@ -44,6 +44,7 @@ internal static partial class CpuSetNativeMethods [Flags] public enum ProcessAccessFlags : uint { + PROCESS_SET_INFORMATION = 0x00000200, PROCESS_QUERY_LIMITED_INFORMATION = 0x00001000, PROCESS_SET_LIMITED_INFORMATION = 0x00002000, } diff --git a/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs new file mode 100644 index 0000000..3bd8164 --- /dev/null +++ b/Platforms/Windows/IProcessMemoryPriorityNativeApi.cs @@ -0,0 +1,88 @@ +/* + * ThreadPilot - Windows process memory priority native API abstraction. + */ +namespace ThreadPilot.Platforms.Windows +{ + using System; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + public interface IProcessMemoryPriorityNativeApi + { + bool IsSupported { get; } + + SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); + + bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + int GetLastWin32Error(); + } + + public sealed class ProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi + { + public static ProcessMemoryPriorityNativeApi Instance { get; } = new(); + + private ProcessMemoryPriorityNativeApi() + { + } + + public bool IsSupported => OperatingSystem.IsWindowsVersionAtLeast(6, 2); + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + return ProcessMemoryPriorityNativeMethods.OpenProcess(access, inheritHandle, processId); + } + + public bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + return ProcessMemoryPriorityNativeMethods.GetProcessInformation( + process, + processInformationClass, + ref processInformation, + processInformationSize); + } + + public bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + return ProcessMemoryPriorityNativeMethods.SetProcessInformation( + process, + processInformationClass, + ref processInformation, + processInformationSize); + } + + public int GetLastWin32Error() + { + return Marshal.GetLastWin32Error(); + } + } + + public enum ProcessInformationClass + { + ProcessMemoryPriority = 0, + } + + [StructLayout(LayoutKind.Sequential)] + public struct MemoryPriorityInformation + { + public uint MemoryPriority; + } +} diff --git a/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs new file mode 100644 index 0000000..cc48cfc --- /dev/null +++ b/Platforms/Windows/ProcessMemoryPriorityNativeMethods.cs @@ -0,0 +1,33 @@ +/* + * ThreadPilot - Windows process memory priority P/Invoke declarations. + */ +namespace ThreadPilot.Platforms.Windows +{ + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + + internal static partial class ProcessMemoryPriorityNativeMethods + { + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial SafeProcessHandle OpenProcess( + ProcessAccessFlags access, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint processId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize); + } +} diff --git a/Services/IProcessMemoryPriorityService.cs b/Services/IProcessMemoryPriorityService.cs new file mode 100644 index 0000000..c1920b6 --- /dev/null +++ b/Services/IProcessMemoryPriorityService.cs @@ -0,0 +1,14 @@ +/* + * ThreadPilot - process memory priority service contract. + */ +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + public interface IProcessMemoryPriorityService + { + Task GetMemoryPriorityAsync(ProcessModel process); + + Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index 218b43b..3b090d8 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -17,7 +17,9 @@ Task> ApplyMatchingRulesAsync( public sealed class PersistentRulesEngine : IPersistentRulesEngine { private const string MissingAffinityErrorCode = "PersistentRuleMissingAffinity"; + private const string MissingMemoryPriorityErrorCode = "PersistentRuleMissingMemoryPriority"; private const string MissingPriorityErrorCode = "PersistentRuleMissingPriority"; + private const string MemoryPriorityApplyFailedErrorCode = "MemoryPriorityApplyFailed"; private const string NoActionsErrorCode = "PersistentRuleNoActions"; private const string PriorityApplyFailedErrorCode = "PriorityApplyFailed"; private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; @@ -26,6 +28,7 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private readonly IPersistentProcessRuleMatcher matcher; private readonly IAffinityApplyService affinityApplyService; private readonly IProcessService processService; + private readonly IProcessMemoryPriorityService memoryPriorityService; private readonly ILogger logger; public PersistentRulesEngine( @@ -33,12 +36,14 @@ public PersistentRulesEngine( IPersistentProcessRuleMatcher matcher, IAffinityApplyService affinityApplyService, IProcessService processService, + IProcessMemoryPriorityService memoryPriorityService, ILogger logger) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.memoryPriorityService = memoryPriorityService ?? throw new ArgumentNullException(nameof(memoryPriorityService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -68,7 +73,7 @@ private async Task ApplyRuleAsync( var result = CreateSuccessResult(rule, process); var success = true; - if (!rule.ApplyAffinityOnStart && !rule.ApplyPriorityOnStart) + if (!rule.ApplyAffinityOnStart && !rule.ApplyPriorityOnStart && !rule.ApplyMemoryPriorityOnStart) { return MarkRuleConfigurationFailure( result, @@ -130,6 +135,35 @@ private async Task ApplyRuleAsync( } } + if (rule.ApplyMemoryPriorityOnStart && !result.IsProcessExited) + { + if (!rule.MemoryPriority.HasValue) + { + success = false; + result = MarkRuleConfigurationFailure( + result, + rule, + MissingMemoryPriorityErrorCode, + "This saved rule has no memory priority value to apply."); + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + var memoryPriorityResult = await this.memoryPriorityService + .SetMemoryPriorityAsync(process, rule.MemoryPriority.Value) + .ConfigureAwait(false); + if (memoryPriorityResult.Success) + { + result = result with { MemoryPriorityApplied = true }; + } + else + { + success = false; + result = this.MergeMemoryPriorityFailure(result, memoryPriorityResult); + } + } + } + return result with { Success = success, @@ -138,6 +172,32 @@ private async Task ApplyRuleAsync( }; } + private PersistentRuleApplyResult MergeMemoryPriorityFailure( + PersistentRuleApplyResult result, + ProcessOperationResult memoryPriorityResult) + { + this.logger.LogWarning( + "Persistent rule memory priority apply failed for rule {RuleId} on process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + memoryPriorityResult.TechnicalMessage); + + return result with + { + ErrorCode = string.IsNullOrWhiteSpace(memoryPriorityResult.ErrorCode) + ? MemoryPriorityApplyFailedErrorCode + : memoryPriorityResult.ErrorCode, + UserMessage = string.IsNullOrWhiteSpace(memoryPriorityResult.UserMessage) + ? "ThreadPilot could not apply the saved memory priority rule." + : memoryPriorityResult.UserMessage, + TechnicalMessage = memoryPriorityResult.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied || memoryPriorityResult.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || memoryPriorityResult.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited || memoryPriorityResult.IsProcessExited, + }; + } + private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) { if (rule.CpuSelection != null) diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs new file mode 100644 index 0000000..0fd9ca1 --- /dev/null +++ b/Services/ProcessMemoryPriorityService.cs @@ -0,0 +1,239 @@ +/* + * ThreadPilot - process memory priority service. + */ +namespace ThreadPilot.Services +{ + using System.ComponentModel; + using System.Runtime.InteropServices; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public sealed class ProcessMemoryPriorityService : IProcessMemoryPriorityService + { + public const string UnsupportedUserMessage = + "Memory priority is not supported on this Windows version or process."; + + private const string InvalidProcessErrorCode = "InvalidProcess"; + private const string UnsupportedErrorCode = "Unsupported"; + private const string InvalidPriorityErrorCode = "InvalidMemoryPriority"; + + private static readonly uint MemoryPriorityInformationSize = + (uint)Marshal.SizeOf(); + + private readonly IProcessMemoryPriorityNativeApi nativeApi; + private readonly ILogger logger; + + public ProcessMemoryPriorityService( + IProcessMemoryPriorityNativeApi nativeApi, + ILogger logger) + { + this.nativeApi = nativeApi ?? throw new ArgumentNullException(nameof(nativeApi)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task GetMemoryPriorityAsync(ProcessModel process) + { + if (!this.nativeApi.IsSupported || !IsValidProcess(process)) + { + return Task.FromResult(null); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + + if (handle.IsInvalid) + { + this.logger.LogDebug( + "OpenProcess failed while reading memory priority for process {ProcessName} (PID: {ProcessId}): {Error}", + process.Name, + process.ProcessId, + this.nativeApi.GetLastWin32Error()); + return Task.FromResult(null); + } + + var information = default(MemoryPriorityInformation); + if (!this.nativeApi.GetProcessInformation( + handle, + ProcessInformationClass.ProcessMemoryPriority, + ref information, + MemoryPriorityInformationSize)) + { + this.logger.LogDebug( + "GetProcessInformation(ProcessMemoryPriority) failed for process {ProcessName} (PID: {ProcessId}): {Error}", + process.Name, + process.ProcessId, + this.nativeApi.GetLastWin32Error()); + return Task.FromResult(null); + } + + return Task.FromResult(FromWindowsMemoryPriority(information.MemoryPriority)); + } + catch (Exception ex) when (IsUnsupported(ex) || AffinityApplyExceptionClassifier.IsAccessDenied(ex) || AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + this.logger.LogDebug( + ex, + "Could not read memory priority for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return Task.FromResult(null); + } + } + + public Task SetMemoryPriorityAsync(ProcessModel process, ProcessMemoryPriority priority) + { + if (!IsValidProcess(process)) + { + return Task.FromResult(ProcessOperationResult.Failed( + InvalidProcessErrorCode, + UnsupportedUserMessage, + "Process is null or has an invalid PID.")); + } + + if (!IsDefinedPriority(priority)) + { + return Task.FromResult(ProcessOperationResult.Failed( + InvalidPriorityErrorCode, + UnsupportedUserMessage, + $"Memory priority value '{priority}' is not supported.")); + } + + if (!this.nativeApi.IsSupported) + { + return Task.FromResult(Unsupported("The Windows process memory priority APIs are unavailable.")); + } + + try + { + using var handle = this.nativeApi.OpenProcess( + ProcessAccessFlags.PROCESS_SET_INFORMATION, + inheritHandle: false, + (uint)process.ProcessId); + + if (handle.IsInvalid) + { + return Task.FromResult(this.FromLastError( + "OpenProcess failed before SetProcessInformation(ProcessMemoryPriority).")); + } + + var information = new MemoryPriorityInformation + { + MemoryPriority = ToWindowsMemoryPriority(priority), + }; + + if (!this.nativeApi.SetProcessInformation( + handle, + ProcessInformationClass.ProcessMemoryPriority, + ref information, + MemoryPriorityInformationSize)) + { + return Task.FromResult(this.FromLastError( + "SetProcessInformation(ProcessMemoryPriority) failed.")); + } + + return Task.FromResult(ProcessOperationResult.Succeeded( + "Memory priority applied.", + $"Process {process.Name} (PID: {process.ProcessId}) memory priority set to {priority}.")); + } + catch (Exception ex) when (IsUnsupported(ex)) + { + return Task.FromResult(Unsupported(ex.Message)); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsProcessExited(ex)) + { + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + ex.Message, + isProcessExited: true)); + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + var antiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return Task.FromResult(ProcessOperationResult.Failed( + antiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : AffinityApplyErrorCodes.AccessDenied, + antiCheatLikely + ? ProcessOperationUserMessages.AntiCheatProtectedLikely + : ProcessOperationUserMessages.AccessDenied, + ex.Message, + isAccessDenied: true, + isAntiCheatLikely: antiCheatLikely)); + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Memory priority apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return Task.FromResult(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "ThreadPilot could not apply the memory priority change.", + ex.Message)); + } + } + + private static bool IsValidProcess(ProcessModel? process) => + process != null && process.ProcessId > 0; + + private static bool IsDefinedPriority(ProcessMemoryPriority priority) => + priority is ProcessMemoryPriority.VeryLow or + ProcessMemoryPriority.Low or + ProcessMemoryPriority.Medium or + ProcessMemoryPriority.BelowNormal or + ProcessMemoryPriority.Normal; + + private static uint ToWindowsMemoryPriority(ProcessMemoryPriority priority) => + IsDefinedPriority(priority) + ? (uint)priority + : throw new ArgumentOutOfRangeException(nameof(priority), priority, "Unsupported memory priority value."); + + private static ProcessMemoryPriority? FromWindowsMemoryPriority(uint priority) => + priority is >= (uint)ProcessMemoryPriority.VeryLow and <= (uint)ProcessMemoryPriority.Normal + ? (ProcessMemoryPriority)priority + : null; + + private static bool IsUnsupported(Exception ex) => + ex is EntryPointNotFoundException || + ex is DllNotFoundException || + (ex is Win32Exception win32Exception && win32Exception.NativeErrorCode == 50); + + private static ProcessOperationResult Unsupported(string technicalMessage) => + ProcessOperationResult.Failed( + UnsupportedErrorCode, + UnsupportedUserMessage, + technicalMessage); + + private ProcessOperationResult FromLastError(string context) + { + var error = this.nativeApi.GetLastWin32Error(); + var technicalMessage = $"{context} Win32 error {error}."; + + return error switch + { + 5 => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + technicalMessage, + isAccessDenied: true), + 50 => Unsupported(technicalMessage), + 87 => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + technicalMessage, + isProcessExited: true), + _ => ProcessOperationResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "ThreadPilot could not apply the memory priority change.", + technicalMessage), + }; + } + } +} diff --git a/Services/ProcessOperationResult.cs b/Services/ProcessOperationResult.cs new file mode 100644 index 0000000..329dced --- /dev/null +++ b/Services/ProcessOperationResult.cs @@ -0,0 +1,48 @@ +/* + * ThreadPilot - process operation result model. + */ +namespace ThreadPilot.Services +{ + public sealed record ProcessOperationResult + { + public bool Success { get; init; } + + public string ErrorCode { get; init; } = string.Empty; + + 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 bool IsProcessExited { get; init; } + + public static ProcessOperationResult Succeeded(string userMessage, string technicalMessage) => + new() + { + Success = true, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + }; + + public static ProcessOperationResult Failed( + string errorCode, + string userMessage, + string technicalMessage, + bool isAccessDenied = false, + bool isAntiCheatLikely = false, + bool isProcessExited = false) => + new() + { + Success = false, + ErrorCode = errorCode, + UserMessage = userMessage, + TechnicalMessage = technicalMessage, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + IsProcessExited = isProcessExited, + }; + } +} diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs index 7e48f78..90d3f46 100644 --- a/Services/ProcessOperationUserMessages.cs +++ b/Services/ProcessOperationUserMessages.cs @@ -8,7 +8,7 @@ internal static class ProcessOperationUserMessages "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."; + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to override it."; public const string AdminClarification = "Administrator mode may help with normal permission issues, but cannot bypass anti-cheat or protected process restrictions."; diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index bebb7da..5e6c007 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -20,6 +20,7 @@ namespace ThreadPilot.Services using System.Net.Http.Headers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; + using ThreadPilot.Platforms.Windows; using ThreadPilot.Services.Abstractions; using ThreadPilot.ViewModels; @@ -105,6 +106,8 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(ProcessMemoryPriorityNativeApi.Instance); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs index bd1eabe..aabde70 100644 --- a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs @@ -89,7 +89,7 @@ public async Task ApplyAsync_WhenAntiCheatProtected_ReturnsProtectedMessageWitho 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("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs index f11b2a4..5fb13e3 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -38,8 +38,10 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() }, LegacyAffinityMask = 3, Priority = ProcessPriorityClass.AboveNormal, + MemoryPriority = ProcessMemoryPriority.BelowNormal, ApplyAffinityOnStart = true, ApplyPriorityOnStart = true, + ApplyMemoryPriorityOnStart = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, Description = ProcessOperationUserMessages.PersistentRulesDescription, @@ -54,6 +56,9 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() var loadedRule = Assert.Single(loaded); Assert.Equal("rule-a", loadedRule.Id); Assert.Equal(3, loadedRule.LegacyAffinityMask); + Assert.Equal(ProcessPriorityClass.AboveNormal, loadedRule.Priority); + Assert.Equal(ProcessMemoryPriority.BelowNormal, loadedRule.MemoryPriority); + Assert.True(loadedRule.ApplyMemoryPriorityOnStart); Assert.NotNull(loadedRule.CpuSelection); Assert.Equal(0, loadedRule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 99f6b78..69d605b 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -17,7 +17,7 @@ public async Task ApplyMatchingRulesAsync_WithCpuSelection_AppliesCpuSelection() var rule = CreateRule(cpuSelection: selection, applyAffinity: true); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var process = CreateProcess(); var results = await engine.ApplyMatchingRulesAsync(process); @@ -35,7 +35,7 @@ public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAf var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var process = CreateProcess(); var results = await engine.ApplyMatchingRulesAsync(process); @@ -53,7 +53,8 @@ public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); var process = CreateProcess(); var results = await engine.ApplyMatchingRulesAsync(process); @@ -64,6 +65,26 @@ public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriority_AppliesMemoryPriority() + { + var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + var process = CreateProcess(); + + var results = await engine.ApplyMatchingRulesAsync(process); + + Assert.Single(results); + Assert.True(results[0].Success); + Assert.True(results[0].MemoryPriorityApplied); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), + Times.Once); + } + [Fact] public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlledFailure() { @@ -73,7 +94,7 @@ public async Task ApplyMatchingRulesAsync_WithRealtimePriority_ReturnsControlled processService .Setup(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime)) .ThrowsAsync(new InvalidOperationException(ProcessOperationUserMessages.RealtimePriorityBlocked)); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -92,7 +113,7 @@ public async Task ApplyMatchingRulesAsync_WithAccessDeniedAffinity_ReturnsAccess ProcessOperationUserMessages.AccessDenied, "Access is denied.", isAccessDenied: true)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -112,7 +133,7 @@ public async Task ApplyMatchingRulesAsync_WithAntiCheatAffinity_ReturnsSafeProte "Protected process.", isAccessDenied: true, isAntiCheatLikely: true)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -131,7 +152,7 @@ public async Task ApplyMatchingRulesAsync_WithProcessExitedAffinity_ReturnsProce ProcessOperationUserMessages.ProcessExited, "Process exited.", failureReason: AffinityApplyFailureReason.ProcessTerminated)); - var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object); + var engine = CreateEngine([rule], affinity.Object, CreateProcessService().Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -147,13 +168,17 @@ public async Task ApplyMatchingRulesAsync_WithDisabledRule_DoesNotApply() var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true) with { IsEnabled = false }; var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); Assert.Empty(results); affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), + Times.Never); } [Fact] @@ -162,7 +187,7 @@ public async Task ApplyMatchingRulesAsync_WithAffinityEnabledButNoAffinityPayloa var rule = CreateRule(applyAffinity: true); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -170,6 +195,7 @@ public async Task ApplyMatchingRulesAsync_WithAffinityEnabledButNoAffinityPayloa Assert.False(result.Success); Assert.False(result.AffinityApplied); Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); Assert.Equal("PersistentRuleMissingAffinity", result.ErrorCode); Assert.Equal("This saved rule has no affinity selection to apply.", result.UserMessage); affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -183,7 +209,7 @@ public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayloa var rule = CreateRule(applyPriority: true); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -191,6 +217,7 @@ public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayloa Assert.False(result.Success); Assert.False(result.AffinityApplied); Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); Assert.Equal("PersistentRuleMissingPriority", result.ErrorCode); Assert.Equal("This saved rule has no priority value to apply.", result.UserMessage); affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -198,13 +225,83 @@ public async Task ApplyMatchingRulesAsync_WithPriorityEnabledButNoPriorityPayloa processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriorityEnabledButNoMemoryPriorityPayload_ReturnsFailure() + { + var rule = CreateRule(applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + + var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.False(result.AffinityApplied); + Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); + Assert.Equal("PersistentRuleMissingMemoryPriority", result.ErrorCode); + Assert.Equal("This saved rule has no memory priority value to apply.", result.UserMessage); + memoryPriorityService.Verify( + s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithAffinityPriorityAndMemoryPriority_AppliesAllFlags() + { + var rule = CreateRule( + legacyAffinityMask: 3, + priority: ProcessPriorityClass.AboveNormal, + memoryPriority: ProcessMemoryPriority.BelowNormal, + applyAffinity: true, + applyPriority: true, + applyMemoryPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var memoryPriorityService = CreateMemoryPriorityService(); + var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.True(result.Success); + Assert.True(result.AffinityApplied); + Assert.True(result.PriorityApplied); + Assert.True(result.MemoryPriorityApplied); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WithMemoryPriorityAccessDenied_PropagatesAccessDeniedResult() + { + var rule = CreateRule(memoryPriority: ProcessMemoryPriority.Low, applyMemoryPriority: true); + var memoryPriorityService = CreateMemoryPriorityService(ProcessOperationResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + "Access is denied.", + isAccessDenied: true)); + var engine = CreateEngine( + [rule], + CreateAffinityService().Object, + CreateProcessService().Object, + memoryPriorityService.Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.False(result.Success); + Assert.False(result.MemoryPriorityApplied); + Assert.True(result.IsAccessDenied); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + [Fact] public async Task ApplyMatchingRulesAsync_WithNoActions_ReturnsControlledFailure() { var rule = CreateRule(); var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine([rule], affinity.Object, processService.Object); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -212,6 +309,7 @@ public async Task ApplyMatchingRulesAsync_WithNoActions_ReturnsControlledFailure Assert.False(result.Success); Assert.False(result.AffinityApplied); Assert.False(result.PriorityApplied); + Assert.False(result.MemoryPriorityApplied); Assert.Equal("PersistentRuleNoActions", result.ErrorCode); affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -228,7 +326,7 @@ public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResul }; var affinity = CreateAffinityService(); var processService = CreateProcessService(); - var engine = CreateEngine(rules, affinity.Object, processService.Object); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); var results = await engine.ApplyMatchingRulesAsync(CreateProcess()); @@ -242,12 +340,14 @@ public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResul private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, - IProcessService processService) => + IProcessService processService, + IProcessMemoryPriorityService memoryPriorityService) => new( new FakePersistentProcessRuleStore(rules), new PersistentProcessRuleMatcher(), affinityApplyService, processService, + memoryPriorityService, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); private static Mock CreateAffinityService(AffinityApplyResult? result = null) @@ -272,13 +372,26 @@ private static Mock CreateProcessService() return mock; } + private static Mock CreateMemoryPriorityService(ProcessOperationResult? result = null) + { + var mock = new Mock(MockBehavior.Strict); + mock + .Setup(s => s.SetMemoryPriorityAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(result ?? ProcessOperationResult.Succeeded( + "Memory priority applied.", + "Memory priority applied in test.")); + return mock; + } + private static PersistentProcessRule CreateRule( string id = "rule", CpuSelection? cpuSelection = null, long? legacyAffinityMask = null, ProcessPriorityClass? priority = null, + ProcessMemoryPriority? memoryPriority = null, bool applyAffinity = false, - bool applyPriority = false) => + bool applyPriority = false, + bool applyMemoryPriority = false) => new() { Id = id, @@ -288,8 +401,10 @@ private static PersistentProcessRule CreateRule( CpuSelection = cpuSelection, LegacyAffinityMask = legacyAffinityMask, Priority = priority, + MemoryPriority = memoryPriority, ApplyAffinityOnStart = applyAffinity, ApplyPriorityOnStart = applyPriority, + ApplyMemoryPriorityOnStart = applyMemoryPriority, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs new file mode 100644 index 0000000..6923853 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -0,0 +1,227 @@ +/* + * ThreadPilot - process memory priority service tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.ComponentModel; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + using ThreadPilot.Services; + + public sealed class ProcessMemoryPriorityServiceTests + { + [Fact] + public async Task SetMemoryPriorityAsync_WithValidProcess_CallsNativeApi() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi(); + var service = CreateService(nativeApi); + var process = CreateProcess(); + + var result = await service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low); + + Assert.True(result.Success); + Assert.Equal(ProcessMemoryPriority.Low, nativeApi.LastSetPriority); + Assert.Equal(ProcessAccessFlags.PROCESS_SET_INFORMATION, nativeApi.LastOpenAccess); + Assert.Equal("Memory priority applied.", result.UserMessage); + } + + [Fact] + public async Task GetMemoryPriorityAsync_WithValidProcess_ReadsNativeApi() + { + var nativeApi = new FakeProcessMemoryPriorityNativeApi + { + PriorityToReturn = ProcessMemoryPriority.BelowNormal, + }; + var service = CreateService(nativeApi); + + var priority = await service.GetMemoryPriorityAsync(CreateProcess()); + + Assert.Equal(ProcessMemoryPriority.BelowNormal, priority); + Assert.Equal(ProcessAccessFlags.PROCESS_QUERY_LIMITED_INFORMATION, nativeApi.LastOpenAccess); + } + + [Theory] + [InlineData(1, ProcessMemoryPriority.VeryLow)] + [InlineData(2, ProcessMemoryPriority.Low)] + [InlineData(3, ProcessMemoryPriority.Medium)] + [InlineData(4, ProcessMemoryPriority.BelowNormal)] + [InlineData(5, ProcessMemoryPriority.Normal)] + public void ProcessMemoryPriority_UsesDocumentedWindowsLevels(int windowsLevel, ProcessMemoryPriority priority) + { + Assert.Equal(windowsLevel, (int)priority); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithNullProcess_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(null!, ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("InvalidProcess", result.ErrorCode); + Assert.False(result.IsAccessDenied); + Assert.False(result.IsProcessExited); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithInvalidPid_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(new ProcessModel { ProcessId = 0 }, ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("InvalidProcess", result.ErrorCode); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenProcessExited_ReturnsProcessExitedFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + OpenException = new InvalidOperationException("The process has exited."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenAccessDenied_ReturnsSafeAccessDeniedFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetException = new Win32Exception(5, "Access is denied."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(AffinityApplyErrorCodes.AccessDenied, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenProtectedByAntiCheat_ReturnsMessageWithoutBypassPromise() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetException = new UnauthorizedAccessException("Protected anti-cheat process."), + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.True(result.IsAntiCheatLikely); + Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenUnsupported_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi { IsSupported = false }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal("Unsupported", result.ErrorCode); + Assert.Equal(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WhenNativeCallFails_ReturnsControlledFailure() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi + { + SetResult = false, + LastError = 31, + }); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), ProcessMemoryPriority.Normal); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.NativeApplyFailed, result.ErrorCode); + Assert.Contains("SetProcessInformation(ProcessMemoryPriority) failed", result.TechnicalMessage); + } + + private static ProcessMemoryPriorityService CreateService(IProcessMemoryPriorityNativeApi nativeApi) => + new(nativeApi, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 42, + Name = "game.exe", + ExecutablePath = @"C:\Games\Game.exe", + }; + + private sealed class FakeProcessMemoryPriorityNativeApi : IProcessMemoryPriorityNativeApi + { + public bool IsSupported { get; init; } = true; + + public ProcessAccessFlags LastOpenAccess { get; private set; } + + public ProcessMemoryPriority? LastSetPriority { get; private set; } + + public ProcessMemoryPriority PriorityToReturn { get; init; } = ProcessMemoryPriority.Normal; + + public Exception? OpenException { get; init; } + + public Exception? SetException { get; init; } + + public bool SetResult { get; init; } = true; + + public int LastError { get; init; } + + public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) + { + this.LastOpenAccess = access; + if (this.OpenException != null) + { + throw this.OpenException; + } + + return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); + } + + public bool GetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + processInformation.MemoryPriority = (uint)this.PriorityToReturn; + return true; + } + + public bool SetProcessInformation( + SafeProcessHandle process, + ProcessInformationClass processInformationClass, + ref MemoryPriorityInformation processInformation, + uint processInformationSize) + { + if (this.SetException != null) + { + throw this.SetException; + } + + this.LastSetPriority = (ProcessMemoryPriority)processInformation.MemoryPriority; + return this.SetResult; + } + + public int GetLastWin32Error() => this.LastError; + } + } +} From bc085725ff933398f41645f712ad71abd9f5abc2 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Wed, 20 May 2026 21:42:35 +0200 Subject: [PATCH 2/2] Refine memory priority failure messages --- Services/ProcessMemoryPriorityService.cs | 7 ++++-- Services/ProcessOperationUserMessages.cs | 2 +- .../AffinityApplyServiceTests.cs | 5 +++- .../ProcessMemoryPriorityServiceTests.cs | 25 +++++++++++++++++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Services/ProcessMemoryPriorityService.cs b/Services/ProcessMemoryPriorityService.cs index 0fd9ca1..6ea3e7a 100644 --- a/Services/ProcessMemoryPriorityService.cs +++ b/Services/ProcessMemoryPriorityService.cs @@ -14,6 +14,9 @@ public sealed class ProcessMemoryPriorityService : IProcessMemoryPriorityService public const string UnsupportedUserMessage = "Memory priority is not supported on this Windows version or process."; + private const string InvalidMemoryPriorityUserMessage = + "This memory priority value is not supported."; + private const string InvalidProcessErrorCode = "InvalidProcess"; private const string UnsupportedErrorCode = "Unsupported"; private const string InvalidPriorityErrorCode = "InvalidMemoryPriority"; @@ -90,7 +93,7 @@ public Task SetMemoryPriorityAsync(ProcessModel process, { return Task.FromResult(ProcessOperationResult.Failed( InvalidProcessErrorCode, - UnsupportedUserMessage, + ProcessOperationUserMessages.ProcessExited, "Process is null or has an invalid PID.")); } @@ -98,7 +101,7 @@ public Task SetMemoryPriorityAsync(ProcessModel process, { return Task.FromResult(ProcessOperationResult.Failed( InvalidPriorityErrorCode, - UnsupportedUserMessage, + InvalidMemoryPriorityUserMessage, $"Memory priority value '{priority}' is not supported.")); } diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs index 90d3f46..7e48f78 100644 --- a/Services/ProcessOperationUserMessages.cs +++ b/Services/ProcessOperationUserMessages.cs @@ -8,7 +8,7 @@ internal static class ProcessOperationUserMessages "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 override it."; + "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."; diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs index aabde70..1b3f115 100644 --- a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs @@ -89,7 +89,10 @@ public async Task ApplyAsync_WhenAntiCheatProtected_ReturnsProtectedMessageWitho Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); Assert.True(result.IsAccessDenied); Assert.True(result.IsAntiCheatLikely); - Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", + ProcessOperationUserMessages.AntiCheatProtectedLikely); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs index 6923853..4988659 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs @@ -62,12 +62,14 @@ public async Task SetMemoryPriorityAsync_WithNullProcess_ReturnsControlledFailur Assert.False(result.Success); Assert.Equal("InvalidProcess", result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); Assert.False(result.IsAccessDenied); Assert.False(result.IsProcessExited); } [Fact] - public async Task SetMemoryPriorityAsync_WithInvalidPid_ReturnsControlledFailure() + public async Task SetMemoryPriorityAsync_WithInvalidProcess_DoesNotReturnUnsupportedWindowsMessage() { var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); @@ -75,6 +77,21 @@ public async Task SetMemoryPriorityAsync_WithInvalidPid_ReturnsControlledFailure Assert.False(result.Success); Assert.Equal("InvalidProcess", result.ErrorCode); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); + } + + [Fact] + public async Task SetMemoryPriorityAsync_WithInvalidPriority_ReturnsInvalidPriorityMessage() + { + var service = CreateService(new FakeProcessMemoryPriorityNativeApi()); + + var result = await service.SetMemoryPriorityAsync(CreateProcess(), (ProcessMemoryPriority)99); + + Assert.False(result.Success); + Assert.Equal("InvalidMemoryPriority", result.ErrorCode); + Assert.Equal("This memory priority value is not supported.", result.UserMessage); + Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage); } [Fact] @@ -124,7 +141,11 @@ public async Task SetMemoryPriorityAsync_WhenProtectedByAntiCheat_ReturnsMessage Assert.True(result.IsAntiCheatLikely); Assert.Equal(AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely, result.ErrorCode); Assert.Equal(ProcessOperationUserMessages.AntiCheatProtectedLikely, result.UserMessage); - Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.Equal( + "The process appears protected by anti-cheat or process protection. ThreadPilot will not try to bypass it.", + ProcessOperationUserMessages.AntiCheatProtectedLikely); + Assert.DoesNotContain("disable anti-cheat", result.UserMessage, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("administrator", result.UserMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("cannot bypass anti-cheat", ProcessOperationUserMessages.AdminClarification); }