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..6ea3e7a
--- /dev/null
+++ b/Services/ProcessMemoryPriorityService.cs
@@ -0,0 +1,242 @@
+/*
+ * 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 InvalidMemoryPriorityUserMessage =
+ "This memory priority value is not supported.";
+
+ 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,
+ ProcessOperationUserMessages.ProcessExited,
+ "Process is null or has an invalid PID."));
+ }
+
+ if (!IsDefinedPriority(priority))
+ {
+ return Task.FromResult(ProcessOperationResult.Failed(
+ InvalidPriorityErrorCode,
+ InvalidMemoryPriorityUserMessage,
+ $"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/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..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.Contains("will not try to 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/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..4988659
--- /dev/null
+++ b/Tests/ThreadPilot.Core.Tests/ProcessMemoryPriorityServiceTests.cs
@@ -0,0 +1,248 @@
+/*
+ * 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.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage);
+ Assert.NotEqual(ProcessMemoryPriorityService.UnsupportedUserMessage, result.UserMessage);
+ Assert.False(result.IsAccessDenied);
+ Assert.False(result.IsProcessExited);
+ }
+
+ [Fact]
+ public async Task SetMemoryPriorityAsync_WithInvalidProcess_DoesNotReturnUnsupportedWindowsMessage()
+ {
+ 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);
+ 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]
+ 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.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);
+ }
+
+ [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;
+ }
+ }
+}