From d2748d8a7c102d0515a47970f1cf61cef26e6df8 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 12:14:38 +0200 Subject: [PATCH 1/2] Apply persistent rules on process discovery --- Models/ApplicationSettingsModel.cs | 4 + Services/PersistentRuleAutoApplyService.cs | 293 ++++++++++++++++ Services/PersistentRulesEngine.cs | 6 +- Services/ProcessMonitorManagerService.cs | 67 +++- Services/ServiceConfiguration.cs | 1 + .../ApplicationSettingsModelTests.cs | 1 + .../PersistentRuleAutoApplyServiceTests.cs | 329 ++++++++++++++++++ .../ProcessMonitorManagerServiceTests.cs | 58 ++- 8 files changed, 755 insertions(+), 4 deletions(-) create mode 100644 Services/PersistentRuleAutoApplyService.cs create mode 100644 Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 734d8ba..be6f208 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -164,6 +164,9 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel [ObservableProperty] private bool enableFallbackPolling = true; + [ObservableProperty] + private bool applyPersistentRulesOnProcessStart = true; + // Advanced Settings [ObservableProperty] private bool enableDebugLogging = false; @@ -249,6 +252,7 @@ public void CopyFrom(ApplicationSettingsModel other) this.FallbackPollingIntervalMs = other.FallbackPollingIntervalMs; this.EnableWmiMonitoring = other.EnableWmiMonitoring; this.EnableFallbackPolling = other.EnableFallbackPolling; + this.ApplyPersistentRulesOnProcessStart = other.ApplyPersistentRulesOnProcessStart; // Advanced Settings this.EnableDebugLogging = other.EnableDebugLogging; diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs new file mode 100644 index 0000000..5de295e --- /dev/null +++ b/Services/PersistentRuleAutoApplyService.cs @@ -0,0 +1,293 @@ +/* + * ThreadPilot - persistent rule runtime auto-apply coordinator. + */ +namespace ThreadPilot.Services +{ + using System.Collections.Concurrent; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IPersistentRuleAutoApplyService + { + Task> ApplyForDiscoveredProcessesAsync( + IEnumerable processes, + CancellationToken cancellationToken = default); + + Task> ApplyForProcessStartAsync( + ProcessModel process, + CancellationToken cancellationToken = default); + + void MarkProcessExited(int processId); + } + + public sealed record PersistentRuleAutoApplyResult + { + public bool Success { get; init; } + + public string RuleId { get; init; } = string.Empty; + + public int ProcessId { get; init; } + + public string ProcessName { get; init; } = string.Empty; + + public string? ErrorCode { get; init; } + + public string UserMessage { get; init; } = string.Empty; + + public string TechnicalMessage { get; init; } = string.Empty; + + public bool IsAccessDenied { get; init; } + + public bool IsAntiCheatLikely { get; init; } + + public bool IsProcessExited { get; init; } + + public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyResult result) => + new() + { + Success = result.Success, + RuleId = result.RuleId, + ProcessId = result.ProcessId, + ProcessName = result.ProcessName, + ErrorCode = result.ErrorCode, + UserMessage = result.IsAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : result.UserMessage, + TechnicalMessage = result.TechnicalMessage, + IsAccessDenied = result.IsAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely, + IsProcessExited = result.IsProcessExited, + }; + } + + public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplyService + { + private static readonly TimeSpan DefaultCooldown = TimeSpan.FromSeconds(30); + + private readonly IPersistentProcessRuleStore ruleStore; + private readonly IPersistentProcessRuleMatcher matcher; + private readonly IPersistentRulesEngine rulesEngine; + private readonly IApplicationSettingsService settingsService; + private readonly ILogger logger; + private readonly Func nowProvider; + private readonly TimeSpan cooldown; + private readonly ConcurrentDictionary recentAttempts = new(); + + public PersistentRuleAutoApplyService( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IPersistentRulesEngine rulesEngine, + IApplicationSettingsService settingsService, + ILogger logger) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown) + { + } + + public PersistentRuleAutoApplyService( + IPersistentProcessRuleStore ruleStore, + IPersistentProcessRuleMatcher matcher, + IPersistentRulesEngine rulesEngine, + IApplicationSettingsService settingsService, + ILogger logger, + Func nowProvider, + TimeSpan cooldown) + { + this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); + this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + this.rulesEngine = rulesEngine ?? throw new ArgumentNullException(nameof(rulesEngine)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); + this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; + } + + public async Task> ApplyForDiscoveredProcessesAsync( + IEnumerable processes, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(processes); + + var snapshot = processes + .Where(IsProcessEligible) + .GroupBy(process => process.ProcessId) + .Select(group => group.First()) + .ToList(); + this.ClearAttemptsForMissingProcesses(snapshot.Select(process => process.ProcessId).ToHashSet()); + + if (!this.IsEnabled() || snapshot.Count == 0) + { + return Array.Empty(); + } + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + if (rules.Count == 0) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var process in snapshot) + { + cancellationToken.ThrowIfCancellationRequested(); + results.AddRange(await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false)); + } + + return results; + } + + public async Task> ApplyForProcessStartAsync( + ProcessModel process, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(process); + + if (!this.IsEnabled() || !IsProcessEligible(process)) + { + return Array.Empty(); + } + + var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + return await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false); + } + + public void MarkProcessExited(int processId) + { + foreach (var key in this.recentAttempts.Keys.Where(key => key.ProcessId == processId)) + { + this.recentAttempts.TryRemove(key, out _); + } + } + + private async Task> ApplyForProcessAsync( + ProcessModel process, + IReadOnlyList rules, + CancellationToken cancellationToken) + { + var now = this.nowProvider(); + var candidates = rules + .Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process)) + .ToList(); + + if (candidates.Count == 0) + { + return Array.Empty(); + } + + var selectedRules = candidates + .Where(rule => this.TryRecordAttempt(process.ProcessId, rule, now)) + .ToList(); + + if (selectedRules.Count == 0) + { + this.logger.LogDebug( + "Persistent rule auto-apply suppressed by cooldown for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return Array.Empty(); + } + + var selectedSignatures = selectedRules + .Select(GetRuleSignature) + .ToHashSet(StringComparer.Ordinal); + + try + { + // Runtime auto-apply only runs while ThreadPilot is open; it does not use registry, + // IFEO, services, or protected-process bypass techniques. + var applyResults = await this.rulesEngine + .ApplyMatchingRulesAsync( + process, + rule => selectedSignatures.Contains(GetRuleSignature(rule)), + cancellationToken) + .ConfigureAwait(false); + + var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList(); + foreach (var result in results) + { + this.LogResult(result); + } + + return results; + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return selectedRules + .Select(rule => new PersistentRuleAutoApplyResult + { + Success = false, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "ThreadPilot could not apply the saved rule.", + TechnicalMessage = ex.Message, + }) + .ToList(); + } + } + + private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now) + { + var key = new RuleAttemptKey(processId, GetRuleSignature(rule)); + if (this.recentAttempts.TryGetValue(key, out var lastAttempt) && + now - lastAttempt < this.cooldown) + { + return false; + } + + this.recentAttempts[key] = now; + return true; + } + + private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) + { + foreach (var key in this.recentAttempts.Keys.Where(key => !currentProcessIds.Contains(key.ProcessId))) + { + this.recentAttempts.TryRemove(key, out _); + } + } + + private void LogResult(PersistentRuleAutoApplyResult result) + { + if (result.Success) + { + this.logger.LogInformation( + "Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId})", + result.RuleId, + result.ProcessName, + result.ProcessId); + return; + } + + var logLevel = result.IsAccessDenied || result.IsAntiCheatLikely || result.IsProcessExited + ? LogLevel.Debug + : LogLevel.Warning; + this.logger.Log( + logLevel, + "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + result.UserMessage); + } + + private bool IsEnabled() => + this.settingsService.Settings.ApplyPersistentRulesOnProcessStart; + + private static bool IsProcessEligible(ProcessModel process) => + process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name); + + private static string GetRuleSignature(PersistentProcessRule rule) => + string.Join( + "|", + string.IsNullOrWhiteSpace(rule.Id) ? rule.Name : rule.Id, + rule.UpdatedAt.ToUniversalTime().Ticks); + + private readonly record struct RuleAttemptKey(int ProcessId, string RuleSignature); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index 3b090d8..d854f98 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -11,6 +11,7 @@ public interface IPersistentRulesEngine { Task> ApplyMatchingRulesAsync( ProcessModel process, + Predicate? ruleFilter = null, CancellationToken cancellationToken = default); } @@ -49,6 +50,7 @@ public PersistentRulesEngine( public async Task> ApplyMatchingRulesAsync( ProcessModel process, + Predicate? ruleFilter = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(process); @@ -56,7 +58,9 @@ public async Task> ApplyMatchingRulesAs var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); var results = new List(); - foreach (var rule in rules.Where(rule => this.matcher.IsMatch(rule, process))) + foreach (var rule in rules.Where(rule => + (ruleFilter == null || ruleFilter(rule)) && + this.matcher.IsMatch(rule, process))) { cancellationToken.ThrowIfCancellationRequested(); results.Add(await this.ApplyRuleAsync(rule, process, cancellationToken).ConfigureAwait(false)); diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index 9b6e9e1..f25df65 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -39,6 +39,7 @@ public class ProcessMonitorManagerService : IProcessMonitorManagerService private readonly IProcessService processService; private readonly ICoreMaskService coreMaskService; private readonly IAffinityApplyService affinityApplyService; + private readonly IPersistentRuleAutoApplyService persistentRuleAutoApplyService; private readonly PowerPlanTransitionGate powerPlanTransitionGate; private readonly ILogger logger; private readonly IEnhancedLoggingService enhancedLogger; @@ -74,6 +75,7 @@ public ProcessMonitorManagerService( IProcessService processService, ICoreMaskService coreMaskService, IAffinityApplyService affinityApplyService, + IPersistentRuleAutoApplyService persistentRuleAutoApplyService, PowerPlanTransitionGate powerPlanTransitionGate, ILogger logger, IEnhancedLoggingService enhancedLogger) @@ -86,6 +88,7 @@ public ProcessMonitorManagerService( this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService)); this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.persistentRuleAutoApplyService = persistentRuleAutoApplyService ?? throw new ArgumentNullException(nameof(persistentRuleAutoApplyService)); this.powerPlanTransitionGate = powerPlanTransitionGate ?? throw new ArgumentNullException(nameof(powerPlanTransitionGate)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); @@ -193,6 +196,7 @@ public async Task StopAsync() { this.coreMaskService.UnregisterMaskApplication(processId); this.processService.UntrackProcess(processId); + this.persistentRuleAutoApplyService.MarkProcessExited(processId); } this.runningAssociatedProcesses.Clear(); @@ -224,7 +228,8 @@ public async Task EvaluateCurrentProcessesAsync() try { - var currentProcesses = await this.processMonitorService.GetRunningProcessesAsync(); + var currentProcesses = (await this.processMonitorService.GetRunningProcessesAsync()).ToList(); + await this.ApplyPersistentRulesForDiscoveredProcessesAsync(currentProcesses); var associatedProcesses = new List(); var currentProcessIds = new HashSet(currentProcesses.Select(p => p.ProcessId)); @@ -235,6 +240,7 @@ public async Task EvaluateCurrentProcessesAsync() { this.coreMaskService.UnregisterMaskApplication(trackedPid); this.processService.UntrackProcess(trackedPid); + this.persistentRuleAutoApplyService.MarkProcessExited(trackedPid); } } @@ -363,6 +369,8 @@ await this.enhancedLogger.LogProcessMonitoringEventAsync( LogEventTypes.ProcessMonitoring.Started, e.Process.Name, e.Process.ProcessId, "Process started and detected by monitoring"); + await this.ApplyPersistentRulesForProcessStartAsync(e.Process); + var association = this.configuration.FindMatchingAssociation(e.Process); if (association != null) { @@ -421,6 +429,8 @@ private async Task OnProcessStoppedAsync(ProcessEventArgs e) try { + this.persistentRuleAutoApplyService.MarkProcessExited(e.Process.ProcessId); + if (this.runningAssociatedProcesses.TryRemove(e.Process.ProcessId, out _)) { this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId); @@ -602,6 +612,60 @@ private void SetStatus(bool isRunning, string status, string? details = null, Ex } } + private async Task ApplyPersistentRulesForDiscoveredProcessesAsync(IEnumerable processes) + { + try + { + var results = await this.persistentRuleAutoApplyService.ApplyForDiscoveredProcessesAsync(processes); + await this.LogPersistentRuleResultsAsync(results); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Persistent rule auto-apply failed during process snapshot refresh"); + } + } + + private async Task ApplyPersistentRulesForProcessStartAsync(ProcessModel process) + { + try + { + var results = await this.persistentRuleAutoApplyService.ApplyForProcessStartAsync(process); + await this.LogPersistentRuleResultsAsync(results); + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule auto-apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + } + } + + private async Task LogPersistentRuleResultsAsync(IReadOnlyList results) + { + foreach (var result in results) + { + if (result.Success) + { + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + result.ProcessName, + result.ProcessId, + $"Persistent rule '{result.RuleId}' applied automatically"); + } + else + { + this.logger.LogDebug( + "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", + result.RuleId, + result.ProcessName, + result.ProcessId, + result.UserMessage); + } + } + } + public void UpdateSettings() { // Update the process monitor service with new settings @@ -834,4 +898,3 @@ public void Dispose() } } } - diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index ea72718..cb661f1 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -112,6 +112,7 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs index 1ec5742..d824989 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs @@ -11,6 +11,7 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() Assert.True(settings.AutostartWithWindows); Assert.False(settings.StartMinimized); + Assert.True(settings.ApplyPersistentRulesOnProcessStart); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs new file mode 100644 index 0000000..8054dae --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -0,0 +1,329 @@ +/* + * ThreadPilot - persistent rule auto-apply coordinator tests. + */ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class PersistentRuleAutoApplyServiceTests + { + [Fact] + public async Task ApplyForProcessStartAsync_WhenMatchingEnabledRuleExists_CallsRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Single(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRuleIsDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule() with { IsEnabled = false }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenNoRuleMatches_DoesNotCallRulesEngine() + { + var process = CreateProcess("editor.exe"); + var rule = CreateRule(processName: "game.exe"); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForProcessStartAsync_DoesNotReapplySameRuleDuringCooldown() + { + var now = DateTimeOffset.UtcNow; + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object, nowProvider: () => now); + + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyForProcessStartAsync_AfterCooldown_RetriesRule() + { + var now = DateTimeOffset.UtcNow; + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object, nowProvider: () => now); + + await service.ApplyForProcessStartAsync(process); + now = now.AddSeconds(31); + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_AfterProcessExit_DoesNotSuppressReusedPid() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForProcessStartAsync(process); + service.MarkProcessExited(process.ProcessId); + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithAccessDeniedFailure_ReturnsFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure(rule, process, ProcessOperationUserMessages.AccessDenied, isAccessDenied: true); + var engine = CreateEngine(rule, failure); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsAccessDenied); + Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithProcessExitedFailure_ReturnsFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure(rule, process, ProcessOperationUserMessages.ProcessExited, isProcessExited: true); + var engine = CreateEngine(rule, failure); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsProcessExited); + Assert.Equal(ProcessOperationUserMessages.ProcessExited, result.UserMessage); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithProtectedProcessFailure_ReturnsSafeFailureWithoutThrowing() + { + var process = CreateProcess(); + var rule = CreateRule(); + var failure = CreateFailure( + rule, + process, + ProcessOperationUserMessages.AntiCheatProtectedLikely, + isAccessDenied: true, + isAntiCheatLikely: true); + var engine = CreateEngine(rule, failure); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.True(result.IsAntiCheatLikely); + Assert.DoesNotContain("bypass", result.UserMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService( + [rule], + engine.Object, + settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); + + var results = await service.ApplyForDiscoveredProcessesAsync([process]); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_ClearsCooldownForProcessesNoLongerPresent() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForDiscoveredProcessesAsync([process]); + await service.ApplyForDiscoveredProcessesAsync([]); + await service.ApplyForDiscoveredProcessesAsync([process]); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + private static PersistentRuleAutoApplyService CreateService( + IReadOnlyList rules, + IPersistentRulesEngine engine, + ApplicationSettingsModel? settings = null, + Func? nowProvider = null) => + new( + new FakePersistentProcessRuleStore(rules), + new PersistentProcessRuleMatcher(), + engine, + CreateSettingsService(settings ?? new ApplicationSettingsModel()), + NullLogger.Instance, + nowProvider ?? (() => DateTimeOffset.UtcNow), + TimeSpan.FromSeconds(30)); + + private static Mock CreateEngine( + PersistentProcessRule rule, + PersistentRuleApplyResult result) + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync((ProcessModel _, Predicate? predicate, CancellationToken _) => + predicate == null || predicate(rule) + ? new[] { result } + : Array.Empty()); + return engine; + } + + private static IApplicationSettingsService CreateSettingsService(ApplicationSettingsModel settings) + { + var settingsService = new Mock(MockBehavior.Loose); + settingsService.SetupGet(x => x.Settings).Returns(settings); + return settingsService.Object; + } + + private static ProcessModel CreateProcess(string name = "game.exe") => + new() + { + ProcessId = 42, + Name = name, + ExecutablePath = @"C:\Games\Game.exe", + Priority = ProcessPriorityClass.Normal, + }; + + private static PersistentProcessRule CreateRule(string id = "rule", string processName = "game.exe") => + new() + { + Id = id, + Name = id, + IsEnabled = true, + ProcessName = processName, + LegacyAffinityMask = 3, + ApplyAffinityOnStart = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static PersistentRuleApplyResult CreateSuccess(PersistentProcessRule rule, ProcessModel process) => + new() + { + Success = true, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + AffinityApplied = true, + UserMessage = "Persistent rule applied.", + TechnicalMessage = "ok", + }; + + private static PersistentRuleApplyResult CreateFailure( + PersistentProcessRule rule, + ProcessModel process, + string userMessage, + bool isAccessDenied = false, + bool isAntiCheatLikely = false, + bool isProcessExited = false) => + new() + { + Success = false, + RuleId = rule.Id, + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = userMessage, + TechnicalMessage = userMessage, + IsAccessDenied = isAccessDenied, + IsAntiCheatLikely = isAntiCheatLikely, + IsProcessExited = isProcessExited, + }; + + private sealed class FakePersistentProcessRuleStore(IReadOnlyList rules) + : IPersistentProcessRuleStore + { + public Task> LoadAsync() => + Task.FromResult(rules); + + public Task SaveAsync(IReadOnlyList rules) => + Task.CompletedTask; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index 0842d8d..b056dee 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -314,6 +314,43 @@ public async Task ProcessStarted_AppliesConfiguredCoreMaskForMatchingProcess() coreMaskService.Verify(x => x.RegisterMaskApplication(31, "mask-game"), Times.Once); } + [Fact] + public async Task ProcessStarted_AppliesPersistentRulesThroughCoordinator() + { + var process = new ProcessModel { ProcessId = 41, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + autoApplyService.Verify( + x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); + autoApplyService.Verify( + x => x.ApplyForProcessStartAsync(process, It.IsAny()), + Times.Once); + } + [Fact] public async Task Dispose_CompletesOnBlockingSynchronizationContext() { @@ -378,7 +415,8 @@ private static ProcessMonitorManagerService CreateService( Mock notificationService, Mock processService, Mock coreMaskService, - Mock affinityApplyService) + Mock affinityApplyService, + Mock? autoApplyService = null) { var enhancedLogger = new Mock(MockBehavior.Loose); enhancedLogger @@ -403,6 +441,7 @@ private static ProcessMonitorManagerService CreateService( processService.Object, coreMaskService.Object, affinityApplyService.Object, + (autoApplyService ?? CreateAutoApplyService()).Object, new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), NullLogger.Instance, enhancedLogger.Object); @@ -464,6 +503,23 @@ private static Mock CreateAffinityApplyService() return affinityApplyService; } + private static Mock CreateAutoApplyService() + { + var autoApplyService = new Mock(MockBehavior.Strict); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + autoApplyService.Setup(x => x.MarkProcessExited(It.IsAny())); + return autoApplyService; + } + private sealed class FakeProcessMonitorService : IProcessMonitorService { public event EventHandler? ProcessStarted; From 2b67b4aba35d81777e0135fc064a1e2940239218 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 12:21:53 +0200 Subject: [PATCH 2/2] Preserve cancellation in rule auto-apply --- Services/PersistentRuleAutoApplyService.cs | 2 +- Services/ProcessMonitorManagerService.cs | 6 + .../PersistentRuleAutoApplyServiceTests.cs | 24 ++++ .../ProcessMonitorManagerServiceTests.cs | 117 +++++++++++++++++- 4 files changed, 146 insertions(+), 3 deletions(-) diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 5de295e..47d642c 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -209,7 +209,7 @@ private async Task> ApplyForProcess return results; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { this.logger.LogWarning( ex, diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index f25df65..51ae8b2 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -619,6 +619,9 @@ private async Task ApplyPersistentRulesForDiscoveredProcessesAsync(IEnumerable

(() => + service.ApplyForProcessStartAsync(process)); + } + [Fact] public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() { @@ -255,6 +267,18 @@ private static Mock CreateEngine( return engine; } + private static Mock CreateEngineThatCancels() + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + return engine; + } + private static IApplicationSettingsService CreateSettingsService(ApplicationSettingsModel settings) { var settingsService = new Mock(MockBehavior.Loose); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index b056dee..a8ef596 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -5,6 +5,7 @@ namespace ThreadPilot.Core.Tests { using System.Collections.ObjectModel; using System.Threading; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; using ThreadPilot.Models; @@ -351,6 +352,84 @@ public async Task ProcessStarted_AppliesPersistentRulesThroughCoordinator() Times.Once); } + [Fact] + public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleSnapshotApplyCancels_DoesNotLogWarning() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 42, Name = "game.exe" }, + }, + }; + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + await manager.EvaluateCurrentProcessesAsync(); + + Assert.Empty(logger.WarningMessages); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyCancels_DoesNotLogWarning() + { + var process = new ProcessModel { ProcessId = 43, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.Empty(logger.WarningMessages); + } + [Fact] public async Task Dispose_CompletesOnBlockingSynchronizationContext() { @@ -416,7 +495,8 @@ private static ProcessMonitorManagerService CreateService( Mock processService, Mock coreMaskService, Mock affinityApplyService, - Mock? autoApplyService = null) + Mock? autoApplyService = null, + ILogger? logger = null) { var enhancedLogger = new Mock(MockBehavior.Loose); enhancedLogger @@ -443,7 +523,7 @@ private static ProcessMonitorManagerService CreateService( affinityApplyService.Object, (autoApplyService ?? CreateAutoApplyService()).Object, new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), - NullLogger.Instance, + logger ?? NullLogger.Instance, enhancedLogger.Object); } @@ -584,5 +664,38 @@ public override void Post(SendOrPostCallback d, object? state) // Intentionally do not pump posted work to emulate a blocked UI thread. } } + + private sealed class CapturingLogger : ILogger + { + public List WarningMessages { get; } = new(); + + public IDisposable? BeginScope(TState state) + where TState : notnull => + NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + this.WarningMessages.Add(formatter(state, exception)); + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } + } } }