diff --git a/Installer/Installer.iss b/Installer/Installer.iss index ffe2d6f..9d35794 100644 --- a/Installer/Installer.iss +++ b/Installer/Installer.iss @@ -5,7 +5,7 @@ #define MyAppPublisher "ThreadPilot" #define MyAppURL "https://github.com/" #define MyAppExeName "ThreadPilot.exe" -#define MyAppVersion "1.4.1" +#define MyAppVersion "1.4.2" #ifndef MyWizardStyle #define MyWizardStyle "modern dynamic windows11" diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs index 25f3f13..8c4f753 100644 --- a/Installer/ThreadPilot.wxs +++ b/Installer/ThreadPilot.wxs @@ -7,7 +7,7 @@ diff --git a/Installer/setup.iss b/Installer/setup.iss index 01ff8af..0576d29 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.4.1" + #define MyAppVersion "1.4.2" #endif #ifndef MyAppSourceDir diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs index 238e46a..90baf7d 100644 --- a/Models/PersistentProcessRule.cs +++ b/Models/PersistentProcessRule.cs @@ -53,6 +53,16 @@ public sealed record PersistentRuleApplyResult public bool PriorityApplied { get; init; } + public ProcessPriorityClass? RequestedPriority { get; init; } + + public ProcessPriorityClass? ObservedPriority { get; init; } + + public bool PriorityVerified { get; init; } + + public bool PriorityVerificationUnavailable { get; init; } + + public string? PriorityVerificationPhase { get; init; } + public bool MemoryPriorityApplied { get; init; } public string? ErrorCode { get; init; } diff --git a/Services/ActivityAuditService.cs b/Services/ActivityAuditService.cs index d2abcfb..c9dea00 100644 --- a/Services/ActivityAuditService.cs +++ b/Services/ActivityAuditService.cs @@ -205,7 +205,8 @@ private static string ResolveCategory(string action) private static ActivityAuditSeverity ResolveSeverity(string action, string details) { - if (ContainsAny(action, "Blocked", "Denied") || ContainsAny(details, "blocked", "denied", "anti-cheat", "protected")) + if (ContainsAny(action, "Blocked", "Denied", "NotObserved", "Unavailable", "Reverted") || + ContainsAny(details, "blocked", "denied", "anti-cheat", "protected", "not observed", "reverted")) { return ActivityAuditSeverity.Warning; } diff --git a/Services/IProcessService.cs b/Services/IProcessService.cs index 3c95f9b..1b1a085 100644 --- a/Services/IProcessService.cs +++ b/Services/IProcessService.cs @@ -16,6 +16,8 @@ public interface IProcessService Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority); + Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority, ProcessPriorityWriteSource source); + Task SaveProcessProfile(string profileName, ProcessModel process); Task LoadProcessProfile(string profileName, ProcessModel process); diff --git a/Services/PersistentProcessRuleJsonStore.cs b/Services/PersistentProcessRuleJsonStore.cs index ef88a26..c7c7526 100644 --- a/Services/PersistentProcessRuleJsonStore.cs +++ b/Services/PersistentProcessRuleJsonStore.cs @@ -34,15 +34,19 @@ internal PersistentProcessRuleJsonStore( public async Task> LoadAsync() { var filePath = this.filePathProvider(); + this.logger?.LogDebug("Loading persistent process rules from {FilePath}", filePath); if (!File.Exists(filePath)) { + this.logger?.LogDebug("Persistent process rules file does not exist at {FilePath}", filePath); return []; } try { var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + var rules = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + this.logger?.LogDebug("Loaded {RuleCount} persistent process rules from {FilePath}", rules.Count, filePath); + return rules; } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { @@ -56,14 +60,18 @@ public async Task SaveAsync(IReadOnlyList rules) ArgumentNullException.ThrowIfNull(rules); var filePath = this.filePathProvider(); - var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrWhiteSpace(directory)) + this.logger?.LogDebug("Saving {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); + try { - Directory.CreateDirectory(directory); + var json = JsonSerializer.Serialize(rules, JsonOptions); + await AtomicFileWriter.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + this.logger?.LogDebug("Saved {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + this.logger?.LogError(ex, "Could not save {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); + throw; } - - var json = JsonSerializer.Serialize(rules, JsonOptions); - await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); } } } diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 4e4cc92..50ef349 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -42,6 +42,16 @@ public sealed record PersistentRuleAutoApplyResult public bool IsProcessExited { get; init; } + public System.Diagnostics.ProcessPriorityClass? RequestedPriority { get; init; } + + public System.Diagnostics.ProcessPriorityClass? ObservedPriority { get; init; } + + public bool PriorityVerified { get; init; } + + public bool PriorityVerificationUnavailable { get; init; } + + public string? PriorityVerificationPhase { get; init; } + public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyResult result) => new() { @@ -57,6 +67,11 @@ public static PersistentRuleAutoApplyResult FromApplyResult(PersistentRuleApplyR IsAccessDenied = result.IsAccessDenied, IsAntiCheatLikely = result.IsAntiCheatLikely, IsProcessExited = result.IsProcessExited, + RequestedPriority = result.RequestedPriority, + ObservedPriority = result.ObservedPriority, + PriorityVerified = result.PriorityVerified, + PriorityVerificationUnavailable = result.PriorityVerificationUnavailable, + PriorityVerificationPhase = result.PriorityVerificationPhase, }; } @@ -70,6 +85,7 @@ public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplySer private readonly IApplicationSettingsService settingsService; private readonly ILogger logger; private readonly IActivityAuditService? activityAuditService; + private readonly IPersistentRulePriorityVerificationService? priorityVerificationService; private readonly Func nowProvider; private readonly TimeSpan cooldown; private readonly ConcurrentDictionary recentAttempts = new(); @@ -80,8 +96,9 @@ public PersistentRuleAutoApplyService( IPersistentRulesEngine rulesEngine, IApplicationSettingsService settingsService, ILogger logger, - IActivityAuditService? activityAuditService = null) - : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService) + IActivityAuditService? activityAuditService = null, + IPersistentRulePriorityVerificationService? priorityVerificationService = null) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService) { } @@ -93,7 +110,8 @@ public PersistentRuleAutoApplyService( ILogger logger, Func nowProvider, TimeSpan cooldown, - IActivityAuditService? activityAuditService = null) + IActivityAuditService? activityAuditService = null, + IPersistentRulePriorityVerificationService? priorityVerificationService = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -103,6 +121,7 @@ public PersistentRuleAutoApplyService( this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; this.activityAuditService = activityAuditService; + this.priorityVerificationService = priorityVerificationService; } public async Task> ApplyForDiscoveredProcessesAsync( @@ -133,7 +152,7 @@ public async Task> ApplyForDiscover foreach (var process in snapshot) { cancellationToken.ThrowIfCancellationRequested(); - results.AddRange(await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false)); + results.AddRange(await this.ApplyForProcessAsync(process, rules, "DiscoveredSnapshot", cancellationToken).ConfigureAwait(false)); } return results; @@ -151,7 +170,7 @@ public async Task> ApplyForProcessS } var rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); - return await this.ApplyForProcessAsync(process, rules, cancellationToken).ConfigureAwait(false); + return await this.ApplyForProcessAsync(process, rules, "ProcessStarted", cancellationToken).ConfigureAwait(false); } public void MarkProcessExited(int processId) @@ -160,11 +179,14 @@ public void MarkProcessExited(int processId) { this.recentAttempts.TryRemove(key, out _); } + + this.priorityVerificationService?.MarkProcessExited(processId); } private async Task> ApplyForProcessAsync( ProcessModel process, IReadOnlyList rules, + string source, CancellationToken cancellationToken) { var now = this.nowProvider(); @@ -191,7 +213,7 @@ private async Task> ApplyForProcess } var selectedSignatures = selectedRules - .Select(GetRuleSignature) + .Select(PersistentRuleRuntimeKeys.GetRuleSignature) .ToHashSet(StringComparer.Ordinal); try @@ -201,14 +223,15 @@ private async Task> ApplyForProcess var applyResults = await this.rulesEngine .ApplyMatchingRulesAsync( process, - rule => selectedSignatures.Contains(GetRuleSignature(rule)), + rule => selectedSignatures.Contains(PersistentRuleRuntimeKeys.GetRuleSignature(rule)), cancellationToken) .ConfigureAwait(false); var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList(); foreach (var result in results) { - await this.LogResultAsync(result).ConfigureAwait(false); + await this.LogResultAsync(result, process, source).ConfigureAwait(false); + this.priorityVerificationService?.ScheduleDelayedVerification(result, process, source); } return results; @@ -237,7 +260,7 @@ private async Task> ApplyForProcess private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now) { - var key = new RuleAttemptKey(processId, GetRuleSignature(rule)); + var key = new RuleAttemptKey(processId, PersistentRuleRuntimeKeys.GetRuleSignature(rule)); if (this.recentAttempts.TryGetValue(key, out var lastAttempt) && now - lastAttempt < this.cooldown) { @@ -256,16 +279,22 @@ private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) } } - private async Task LogResultAsync(PersistentRuleAutoApplyResult result) + private async Task LogResultAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source) { if (result.Success) { this.logger.LogInformation( - "Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId})", + "Applied saved persistent rule {RuleId} to process {ProcessName} (PID: {ProcessId}). Source: {Source}; path: {ExecutablePath}; requested priority: {RequestedPriority}; observed priority: {ObservedPriority}; priority verified: {PriorityVerified}; phase: {Phase}", result.RuleId, result.ProcessName, - result.ProcessId); - await this.LogActivityResultAsync(result).ConfigureAwait(false); + result.ProcessId, + source, + process.ExecutablePath, + result.RequestedPriority, + result.ObservedPriority, + result.PriorityVerified, + result.PriorityVerificationPhase); + await this.LogActivityResultAsync(result, process, source).ConfigureAwait(false); return; } @@ -274,27 +303,28 @@ private async Task LogResultAsync(PersistentRuleAutoApplyResult result) : LogLevel.Warning; this.logger.Log( logLevel, - "Persistent rule {RuleId} was not applied to process {ProcessName} (PID: {ProcessId}): {Message}", + "Persistent rule {RuleId} was not fully applied to process {ProcessName} (PID: {ProcessId}): {Message}. Source: {Source}; path: {ExecutablePath}; requested priority: {RequestedPriority}; observed priority: {ObservedPriority}; phase: {Phase}", result.RuleId, result.ProcessName, result.ProcessId, - result.UserMessage); - await this.LogActivityResultAsync(result).ConfigureAwait(false); + result.UserMessage, + source, + process.ExecutablePath, + result.RequestedPriority, + result.ObservedPriority, + result.PriorityVerificationPhase); + await this.LogActivityResultAsync(result, process, source).ConfigureAwait(false); } - private async Task LogActivityResultAsync(PersistentRuleAutoApplyResult result) + private async Task LogActivityResultAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source) { if (this.activityAuditService == null) { return; } - var action = result.Success - ? "PersistentRuleAutoApplied" - : "PersistentRuleAutoApplyFailed"; - var message = result.Success - ? $"Auto-applied saved rule for {result.ProcessName}." - : $"Failed to auto-apply saved rule for {result.ProcessName}: {result.UserMessage}"; + var action = ResolveActivityAction(result); + var message = ResolveActivityMessage(result); try { @@ -302,7 +332,7 @@ await this.activityAuditService .LogUserActionAsync( action, message, - $"Rule: {result.RuleId}, PID: {result.ProcessId}") + $"Rule: {result.RuleId}, PID: {result.ProcessId}, Source: {source}, Path: {process.ExecutablePath}, RequestedPriority: {result.RequestedPriority?.ToString() ?? "none"}, ObservedPriority: {result.ObservedPriority?.ToString() ?? "unavailable"}, Phase: {result.PriorityVerificationPhase ?? "none"}") .ConfigureAwait(false); } catch (Exception ex) @@ -311,18 +341,61 @@ await this.activityAuditService } } + private static string ResolveActivityAction(PersistentRuleAutoApplyResult result) + { + if (result.PriorityVerified) + { + return "PersistentRulePriorityVerified"; + } + + if (result.RequestedPriority.HasValue && result.ObservedPriority.HasValue) + { + return "PersistentRulePriorityNotObserved"; + } + + if (result.RequestedPriority.HasValue && result.PriorityVerificationUnavailable) + { + return "PersistentRulePriorityVerificationUnavailable"; + } + + if (result.RequestedPriority.HasValue && !result.Success) + { + return "PersistentRulePriorityApplyFailed"; + } + + return result.Success + ? "PersistentRuleAutoApplied" + : "PersistentRuleAutoApplyFailed"; + } + + private static string ResolveActivityMessage(PersistentRuleAutoApplyResult result) + { + if (result.PriorityVerified) + { + return $"Saved rule priority verified immediately for {result.ProcessName} as {result.ObservedPriority}."; + } + + if (result.RequestedPriority.HasValue && result.ObservedPriority.HasValue) + { + return $"Saved rule priority requested for {result.ProcessName}, but observed {result.ObservedPriority} instead of {result.RequestedPriority}."; + } + + if (result.RequestedPriority.HasValue && result.PriorityVerificationUnavailable) + { + return $"Saved rule priority requested for {result.ProcessName}, but verification was unavailable: {result.UserMessage}"; + } + + return result.Success + ? $"Auto-applied saved rule for {result.ProcessName}." + : $"Failed to auto-apply saved rule for {result.ProcessName}: {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/PersistentRulePriorityVerificationService.cs b/Services/PersistentRulePriorityVerificationService.cs new file mode 100644 index 0000000..a6e686e --- /dev/null +++ b/Services/PersistentRulePriorityVerificationService.cs @@ -0,0 +1,371 @@ +namespace ThreadPilot.Services +{ + using System.Collections.Concurrent; + using System.Diagnostics; + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public interface IPersistentRulePriorityVerificationService + { + void ScheduleDelayedVerification(PersistentRuleAutoApplyResult result, ProcessModel process, string source); + + void MarkProcessExited(int processId); + } + + public sealed class PersistentRulePriorityVerificationService : IPersistentRulePriorityVerificationService + { + private static readonly TimeSpan[] DefaultDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)]; + private static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromSeconds(4); // ponytail: one conservative startup-settling retry; no polling. + + private readonly IProcessService processService; + private readonly ILogger logger; + private readonly IActivityAuditService? activityAuditService; + private readonly IPersistentProcessRuleStore? ruleStore; + private readonly IPersistentProcessRuleMatcher? matcher; + private readonly IReadOnlyList delays; + private readonly TimeSpan retryDelay; + private readonly Func delayAsync; + private readonly ConcurrentDictionary retryUsed = new(); + private readonly ConcurrentDictionary pendingRetries = new(); + + public PersistentRulePriorityVerificationService( + IProcessService processService, + ILogger logger, + IActivityAuditService? activityAuditService = null, + IPersistentProcessRuleStore? ruleStore = null, + IPersistentProcessRuleMatcher? matcher = null) + : this(processService, logger, activityAuditService, ruleStore, matcher, DefaultDelays, DefaultRetryDelay, Task.Delay) + { + } + + public PersistentRulePriorityVerificationService( + IProcessService processService, + ILogger logger, + IActivityAuditService? activityAuditService, + IReadOnlyList delays, + Func delayAsync) + : this(processService, logger, activityAuditService, null, null, delays, DefaultRetryDelay, delayAsync) + { + } + + public PersistentRulePriorityVerificationService( + IProcessService processService, + ILogger logger, + IActivityAuditService? activityAuditService, + IPersistentProcessRuleStore? ruleStore, + IPersistentProcessRuleMatcher? matcher, + IReadOnlyList delays, + TimeSpan retryDelay, + Func delayAsync) + { + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.activityAuditService = activityAuditService; + this.ruleStore = ruleStore; + this.matcher = matcher; + this.delays = delays?.Take(2).ToArray() ?? DefaultDelays; + this.retryDelay = retryDelay < TimeSpan.Zero ? DefaultRetryDelay : retryDelay; + this.delayAsync = delayAsync ?? Task.Delay; + } + + public void ScheduleDelayedVerification(PersistentRuleAutoApplyResult result, ProcessModel process, string source) + { + if (!ShouldVerify(result)) + { + return; + } + + TaskSafety.FireAndForget( + this.VerifyDelayedAsync(result, process, source, CancellationToken.None), + ex => this.logger.LogDebug(ex, "Persistent rule delayed priority verification failed")); + } + + public void MarkProcessExited(int processId) + { + foreach (var key in this.pendingRetries.Keys.Where(key => key.ProcessId == processId)) + { + if (this.pendingRetries.TryRemove(key, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + } + + foreach (var key in this.retryUsed.Keys.Where(key => key.ProcessId == processId)) + { + this.retryUsed.TryRemove(key, out _); + } + } + + public async Task VerifyDelayedAsync( + PersistentRuleAutoApplyResult result, + ProcessModel process, + string source, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(process); + + if (!ShouldVerify(result)) + { + return; + } + + var requested = result.RequestedPriority!.Value; + for (var i = 0; i < this.delays.Count; i++) + { + var phase = i == 0 ? "delayed" : $"delayed-{i + 1}"; + try + { + await this.delayAsync(this.delays[i], cancellationToken).ConfigureAwait(false); + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + var observed = process.Priority; + if (observed == requested) + { + await this.LogVerifiedAsync(result, process, source, phase, requested, observed, attempt: 0).ConfigureAwait(false); + continue; + } + + await this.LogRevertedAsync(result, process, source, phase, requested, observed).ConfigureAwait(false); + await this.ScheduleRetryAsync(result, process, source, requested, observed).ConfigureAwait(false); + return; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var isExited = AffinityApplyExceptionClassifier.IsProcessExited(ex); + this.logger.Log( + isExited ? LogLevel.Debug : LogLevel.Warning, + ex, + "Persistent rule delayed priority verification unavailable for rule {RuleId} on process {ProcessName} (PID: {ProcessId}). Source: {Source}; requested: {RequestedPriority}; phase: {Phase}; path: {ExecutablePath}", + result.RuleId, + result.ProcessName, + result.ProcessId, + source, + requested, + phase, + process.ExecutablePath); + await this.LogActivityAsync( + "PersistentRulePriorityVerificationUnavailable", + $"Could not verify saved priority for {result.ProcessName}: {ex.Message}", + result, + process, + source, + phase, + requested, + observed: null, + attempt: 0, + reason: ex.Message).ConfigureAwait(false); + return; + } + } + } + + private async Task ScheduleRetryAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source, ProcessPriorityClass requested, ProcessPriorityClass observed) + { + if (this.ruleStore == null || this.matcher == null) + { + return; + } + + var signature = await this.GetCurrentRuleSignature(result.RuleId).ConfigureAwait(false); + if (signature == null) + { + TaskSafety.FireAndForget( + this.LogRetrySkippedAsync(result, process, source, "rule-not-found", requested, observed, "delayed"), + _ => { }); + return; + } + + var key = new RetryKey(result.ProcessId, signature); + if (!this.retryUsed.TryAdd(key, 0)) + { + TaskSafety.FireAndForget( + this.LogRetrySkippedAsync(result, process, source, "retry-already-used", requested, observed, "delayed"), + _ => { }); + return; + } + + var cts = new CancellationTokenSource(); + if (!this.pendingRetries.TryAdd(key, cts)) + { + cts.Dispose(); + return; + } + + TaskSafety.FireAndForget( + this.LogActivityAsync( + "PersistentRulePriorityRetryScheduled", + $"Scheduled one priority retry for {result.ProcessName}: requested {requested}, observed {observed}.", + result, + process, + source, + "delayed", + requested, + observed, + attempt: 1, + reason: $"retry-delay={this.retryDelay.TotalSeconds:0.#}s"), + _ => { }); + + TaskSafety.FireAndForget( + this.RunRetryAsync(key, result, process, source, requested, cts.Token), + ex => this.logger.LogDebug(ex, "Persistent rule priority retry failed")); + } + + private async Task RunRetryAsync(RetryKey key, PersistentRuleAutoApplyResult result, ProcessModel process, string source, ProcessPriorityClass requested, CancellationToken cancellationToken) + { + try + { + await this.delayAsync(this.retryDelay, cancellationToken).ConfigureAwait(false); + var rule = await this.GetRetryRuleAsync(result.RuleId, key.RuleSignature, process).ConfigureAwait(false); + if (rule == null) + { + await this.LogRetrySkippedAsync(result, process, source, "rule-disabled-or-not-matching", requested, process.Priority, "retry-immediate").ConfigureAwait(false); + return; + } + + if (process.Classification == ProcessClassification.ProtectedOrAccessDenied) + { + await this.LogRetrySkippedAsync(result, process, source, "protected-or-access-denied", requested, process.Priority, "retry-immediate").ConfigureAwait(false); + return; + } + + if (!await this.processService.IsProcessStillRunning(process).ConfigureAwait(false)) + { + await this.LogRetrySkippedAsync(result, process, source, "process-exited", requested, process.Priority, "retry-immediate").ConfigureAwait(false); + return; + } + + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + if (process.Priority == requested) + { + await this.LogRetryVerifiedAsync(result, process, source, "retry-immediate", requested, process.Priority).ConfigureAwait(false); + return; + } + + await this.processService.SetProcessPriority(process, requested, ProcessPriorityWriteSource.PersistentRuleRetry).ConfigureAwait(false); + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + if (process.Priority == requested) + { + await this.LogRetryVerifiedAsync(result, process, source, "retry-immediate", requested, process.Priority).ConfigureAwait(false); + } + else + { + await this.LogActivityAsync( + "PersistentRulePriorityRetryNotObserved", + $"Priority retry for {result.ProcessName} was not observed: requested {requested}, observed {process.Priority}.", + result, + process, + source, + "retry-immediate", + requested, + process.Priority, + attempt: 1).ConfigureAwait(false); + return; + } + + var finalDelay = this.delays.Count > 0 ? this.delays[^1] : TimeSpan.Zero; + await this.delayAsync(finalDelay, cancellationToken).ConfigureAwait(false); + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + if (process.Priority == requested) + { + await this.LogRetryVerifiedAsync(result, process, source, "retry-delayed", requested, process.Priority).ConfigureAwait(false); + return; + } + + await this.LogActivityAsync( + "PersistentRulePriorityRetryReverted", + $"Priority retry for {result.ProcessName} reverted again: requested {requested}, observed {process.Priority}.", + result, + process, + source, + "retry-delayed", + requested, + process.Priority, + attempt: 1).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var reason = AffinityApplyExceptionClassifier.IsProcessExited(ex) ? "process-exited" : + AffinityApplyExceptionClassifier.IsAccessDenied(ex) || AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex) ? "access-denied-or-protected" : ex.Message; + var action = reason is "process-exited" or "access-denied-or-protected" + ? "PersistentRulePriorityRetrySkipped" + : "PersistentRulePriorityRetryFailed"; + await this.LogActivityAsync(action, $"Priority retry for {result.ProcessName} did not run: {reason}.", result, process, source, "retry-immediate", requested, null, 1, reason).ConfigureAwait(false); + } + finally + { + if (this.pendingRetries.TryRemove(key, out var cts)) + { + cts.Dispose(); + } + } + } + + private async Task GetCurrentRuleSignature(string ruleId) + { + var rule = (await this.ruleStore!.LoadAsync().ConfigureAwait(false)).FirstOrDefault(rule => rule.Id == ruleId); + return rule == null ? null : PersistentRuleRuntimeKeys.GetRuleSignature(rule); + } + + private async Task GetRetryRuleAsync(string ruleId, string ruleSignature, ProcessModel process) + { + var rule = (await this.ruleStore!.LoadAsync().ConfigureAwait(false)).FirstOrDefault(rule => rule.Id == ruleId); + return rule != null && + rule.IsEnabled && + PersistentRuleRuntimeKeys.GetRuleSignature(rule) == ruleSignature && + this.matcher!.IsMatch(rule, process) + ? rule + : null; + } + + private static bool ShouldVerify(PersistentRuleAutoApplyResult result) => + result.PriorityVerified && result.RequestedPriority.HasValue; + + private Task LogVerifiedAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source, string phase, ProcessPriorityClass requested, ProcessPriorityClass observed, int attempt) + { + this.logger.LogInformation( + "Persistent rule priority verified for rule {RuleId} on process {ProcessName} (PID: {ProcessId}). Source: {Source}; requested: {RequestedPriority}; observed: {ObservedPriority}; phase: {Phase}; path: {ExecutablePath}; attempt: {Attempt}", + result.RuleId, result.ProcessName, result.ProcessId, source, requested, observed, phase, process.ExecutablePath, attempt); + return this.LogActivityAsync("PersistentRulePriorityVerified", $"Verified saved priority for {result.ProcessName}: {observed}.", result, process, source, phase, requested, observed, attempt); + } + + private Task LogRevertedAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source, string phase, ProcessPriorityClass requested, ProcessPriorityClass observed) + { + this.logger.LogWarning( + "Persistent rule priority reverted after apply for rule {RuleId} on process {ProcessName} (PID: {ProcessId}). Source: {Source}; requested: {RequestedPriority}; observed: {ObservedPriority}; phase: {Phase}; path: {ExecutablePath}", + result.RuleId, result.ProcessName, result.ProcessId, source, requested, observed, phase, process.ExecutablePath); + return this.LogActivityAsync("PersistentRulePriorityRevertedAfterApply", $"Saved priority for {result.ProcessName} was reverted: requested {requested}, observed {observed}.", result, process, source, phase, requested, observed, attempt: 0); + } + + private Task LogRetryVerifiedAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source, string phase, ProcessPriorityClass requested, ProcessPriorityClass observed) => + this.LogActivityAsync("PersistentRulePriorityRetryVerified", $"Priority retry for {result.ProcessName} verified as {observed}.", result, process, source, phase, requested, observed, attempt: 1); + + private Task LogRetrySkippedAsync(PersistentRuleAutoApplyResult result, ProcessModel process, string source, string reason, ProcessPriorityClass requested, ProcessPriorityClass? observed, string phase) => + this.LogActivityAsync("PersistentRulePriorityRetrySkipped", $"Priority retry for {result.ProcessName} skipped: {reason}.", result, process, source, phase, requested, observed, attempt: 1, reason: reason); + + private async Task LogActivityAsync( + string action, + string message, + PersistentRuleAutoApplyResult result, + ProcessModel process, + string source, + string phase, + ProcessPriorityClass requested, + ProcessPriorityClass? observed, + int attempt, + string? reason = null) + { + if (this.activityAuditService == null) + { + return; + } + + await this.activityAuditService.LogUserActionAsync( + action, + message, + $"Action: {action}, Rule: {result.RuleId}, PID: {result.ProcessId}, Process: {result.ProcessName}, Source: {source}, Requested: {requested}, Observed: {observed?.ToString() ?? "unavailable"}, Attempt: {attempt}, Phase: {phase}, Reason: {reason ?? "none"}, Path: {process.ExecutablePath}").ConfigureAwait(false); + } + + private readonly record struct RetryKey(int ProcessId, string RuleSignature); + } +} diff --git a/Services/PersistentRuleRuntimeKeys.cs b/Services/PersistentRuleRuntimeKeys.cs new file mode 100644 index 0000000..1e84105 --- /dev/null +++ b/Services/PersistentRuleRuntimeKeys.cs @@ -0,0 +1,13 @@ +namespace ThreadPilot.Services +{ + using ThreadPilot.Models; + + internal static class PersistentRuleRuntimeKeys + { + public static string GetRuleSignature(PersistentProcessRule rule) => + string.Join( + "|", + string.IsNullOrWhiteSpace(rule.Id) ? rule.Name : rule.Id, + rule.UpdatedAt.ToUniversalTime().Ticks); + } +} diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index ddebb51..45f822e 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -23,6 +23,8 @@ public sealed class PersistentRulesEngine : IPersistentRulesEngine private const string MemoryPriorityApplyFailedErrorCode = "MemoryPriorityApplyFailed"; private const string NoActionsErrorCode = "PersistentRuleNoActions"; private const string PriorityApplyFailedErrorCode = "PriorityApplyFailed"; + private const string PriorityNotObservedErrorCode = "PriorityNotObserved"; + private const string PriorityVerificationUnavailableErrorCode = "PriorityVerificationUnavailable"; private const string RealtimePriorityBlockedErrorCode = "RealtimePriorityBlocked"; private readonly IPersistentProcessRuleStore ruleStore; @@ -128,8 +130,10 @@ private async Task ApplyRuleAsync( cancellationToken.ThrowIfCancellationRequested(); try { - await this.processService.SetProcessPriority(process, rule.Priority.Value).ConfigureAwait(false); - result = result with { PriorityApplied = true }; + var requestedPriority = rule.Priority.Value; + await this.processService.SetProcessPriority(process, requestedPriority, ProcessPriorityWriteSource.PersistentRuleInitialApply).ConfigureAwait(false); + result = await this.VerifyPriorityAsync(result, process, requestedPriority).ConfigureAwait(false); + success = success && result.PriorityVerified; } catch (Exception ex) { @@ -202,6 +206,89 @@ private PersistentRuleApplyResult MergeMemoryPriorityFailure( }; } + private async Task VerifyPriorityAsync( + PersistentRuleApplyResult result, + ProcessModel process, + ProcessPriorityClass requestedPriority) + { + try + { + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + var observedPriority = process.Priority; + if (observedPriority == requestedPriority) + { + return result with + { + PriorityApplied = true, + RequestedPriority = requestedPriority, + ObservedPriority = observedPriority, + PriorityVerified = true, + PriorityVerificationPhase = "immediate", + }; + } + + this.logger.LogWarning( + "Persistent rule priority requested but not observed for rule {RuleId} on process {ProcessName} (PID: {ProcessId}). Requested: {RequestedPriority}; observed: {ObservedPriority}; phase: immediate", + result.RuleId, + result.ProcessName, + result.ProcessId, + requestedPriority, + observedPriority); + + return result with + { + Success = false, + PriorityApplied = true, + RequestedPriority = requestedPriority, + ObservedPriority = observedPriority, + PriorityVerificationPhase = "immediate", + ErrorCode = PriorityNotObservedErrorCode, + UserMessage = "ThreadPilot requested the saved priority, but the process reported a different priority.", + TechnicalMessage = $"Requested priority {requestedPriority}; observed {observedPriority} during immediate verification.", + }; + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Persistent rule priority verification unavailable for rule {RuleId} on process {ProcessName} (PID: {ProcessId}). Requested: {RequestedPriority}; phase: immediate", + result.RuleId, + result.ProcessName, + result.ProcessId, + requestedPriority); + + var isProcessExited = AffinityApplyExceptionClassifier.IsProcessExited(ex); + var isAccessDenied = AffinityApplyExceptionClassifier.IsAccessDenied(ex); + var isAntiCheatLikely = AffinityApplyExceptionClassifier.IsAntiCheatLikely(ex); + return result with + { + Success = false, + PriorityApplied = true, + RequestedPriority = requestedPriority, + PriorityVerificationUnavailable = true, + PriorityVerificationPhase = "immediate", + ErrorCode = isProcessExited + ? AffinityApplyErrorCodes.ProcessExited + : isAntiCheatLikely + ? AffinityApplyErrorCodes.AntiCheatOrProtectedProcessLikely + : isAccessDenied + ? AffinityApplyErrorCodes.AccessDenied + : PriorityVerificationUnavailableErrorCode, + UserMessage = isProcessExited + ? ProcessOperationUserMessages.ProcessExited + : isAntiCheatLikely + ? ProcessOperationUserMessages.PersistentRulesProtectedProcessWarning + : isAccessDenied + ? ProcessOperationUserMessages.AccessDenied + : "ThreadPilot requested the saved priority, but could not verify the process priority.", + TechnicalMessage = ex.Message, + IsAccessDenied = result.IsAccessDenied || isAccessDenied, + IsAntiCheatLikely = result.IsAntiCheatLikely || isAntiCheatLikely, + IsProcessExited = result.IsProcessExited || isProcessExited, + }; + } + } + private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) { if (rule.CpuSelection != null) diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index 1c53cc8..a26c309 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -765,7 +765,7 @@ await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.ApplyC this.processService.TrackPriorityChange(process.ProcessId, currentPriority); } - await this.processService.SetProcessPriority(process, priority); + await this.processService.SetProcessPriority(process, priority, ProcessPriorityWriteSource.PowerPlanAssociation); this.logger.LogInformation( "Applied priority '{Priority}' to process {ProcessName} (PID: {ProcessId})", diff --git a/Services/ProcessPriorityWriteSource.cs b/Services/ProcessPriorityWriteSource.cs new file mode 100644 index 0000000..3982753 --- /dev/null +++ b/Services/ProcessPriorityWriteSource.cs @@ -0,0 +1,13 @@ +namespace ThreadPilot.Services +{ + public enum ProcessPriorityWriteSource + { + UnknownInternal, + PersistentRuleInitialApply, + PersistentRuleRetry, + PowerPlanAssociation, + ProcessProfileLoad, + ManualUiAction, + CleanupOrRestore, + } +} diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index fd661b7..fc755e7 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -565,7 +565,10 @@ private async Task ApplyLegacyProcessorAffinityDirectAsync(ProcessModel pr }).ConfigureAwait(false); } - public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) + public Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority) => + this.SetProcessPriority(process, priority, ProcessPriorityWriteSource.UnknownInternal); + + public async Task SetProcessPriority(ProcessModel process, ProcessPriorityClass priority, ProcessPriorityWriteSource source) { ArgumentNullException.ThrowIfNull(process); if (ProcessPriorityGuardrails.IsBlocked(priority)) @@ -599,6 +602,14 @@ await Task.Run(() => targetProcess.PriorityClass = priority; process.Priority = targetProcess.PriorityClass; + this.logger?.LogInformation( + "Set process priority for {ProcessName} (PID: {ProcessId}). Source: {Source}; requested: {RequestedPriority}; observed: {ObservedPriority}; path: {ExecutablePath}", + process.Name, + process.ProcessId, + source, + priority, + process.Priority, + process.ExecutablePath); this.AuditProcessOperation("SetProcessPriority", process.Name, success: true); } catch (Win32Exception ex) when (ex.NativeErrorCode == 5) @@ -707,7 +718,7 @@ public async Task LoadProcessProfile(string profileName, ProcessModel proc private Task SetLoadProcessProfilePriorityAsync(ProcessModel process, ProcessPriorityClass priority) => this.loadProcessProfilePrioritySetter != null ? this.loadProcessProfilePrioritySetter(process, priority) - : this.SetProcessPriority(process, priority); + : this.SetProcessPriority(process, priority, ProcessPriorityWriteSource.ProcessProfileLoad); private Task SetLoadProcessProfileCpuSelectionAsync(ProcessModel process, CpuSelection selection) => this.loadProcessProfileCpuSelectionSetter != null @@ -1336,9 +1347,14 @@ public Task ResetAllProcessPrioritiesAsync() if (this.originalPriorities.TryGetValue(processId, out var originalPriority)) { process.PriorityClass = originalPriority; - this.logger?.LogDebug( - "Reset priority for process {ProcessName} (PID: {ProcessId}) to {Priority}", - process.ProcessName, processId, originalPriority); + this.logger?.LogInformation( + "Set process priority for {ProcessName} (PID: {ProcessId}). Source: {Source}; requested: {RequestedPriority}; observed: {ObservedPriority}; path: {ExecutablePath}", + process.ProcessName, + processId, + ProcessPriorityWriteSource.CleanupOrRestore, + originalPriority, + process.PriorityClass, + string.Empty); } this.originalPriorities.TryRemove(processId, out _); diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 5fb5c07..0697742 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -78,6 +78,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/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index b21abb5..f659ffb 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -4,8 +4,8 @@ namespace ThreadPilot.Core.Tests public sealed partial class PackagingMetadataTests { - private const string ReleaseVersion = "1.4.1"; - private const string ReleaseAssemblyVersion = "1.4.1.0"; + private const string ReleaseVersion = "1.4.2"; + private const string ReleaseAssemblyVersion = "1.4.2.0"; [Fact] public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() @@ -100,7 +100,7 @@ private static string FindRepositoryRoot() throw new InvalidOperationException("Repository root could not be located."); } - [GeneratedRegex("#define MyAppVersion \"1\\.4\\.1\"", RegexOptions.CultureInvariant)] + [GeneratedRegex("#define MyAppVersion \"1\\.4\\.2\"", RegexOptions.CultureInvariant)] private static partial Regex MyAppVersionRegex(); } } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs index 94529e1..dc24386 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -68,6 +68,74 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() } } + [Fact] + public async Task MultipleRules_SaveThenReload_DoesNotLoseEntries() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var rules = new[] + { + CreateRule("rule-a", "PersistenceProbe.exe", ProcessPriorityClass.High), + CreateRule("rule-b", "AnotherProbe.exe", ProcessPriorityClass.AboveNormal), + }; + + try + { + await store.SaveAsync(rules); + + var loaded = await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync(); + + Assert.Equal(2, loaded.Count); + Assert.Contains(loaded, rule => rule.Id == "rule-a" && rule.ProcessName == "PersistenceProbe.exe"); + Assert.Contains(loaded, rule => rule.Id == "rule-b" && rule.ProcessName == "AnotherProbe.exe"); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task CpuPriority_SerializationRoundTrip() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var rule = CreateRule("rule-priority", "PersistenceProbe.exe", ProcessPriorityClass.High); + + try + { + await store.SaveAsync([rule]); + + var loaded = await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync(); + + var loadedRule = Assert.Single(loaded); + Assert.Equal(ProcessPriorityClass.High, loadedRule.Priority); + Assert.True(loadedRule.ApplyPriorityOnStart); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task SaveAsync_WithMissingDirectory_CreatesDirectory() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + try + { + await store.SaveAsync([CreateRule("rule-a", "PersistenceProbe.exe", ProcessPriorityClass.High)]); + + Assert.True(File.Exists(filePath)); + } + finally + { + DeleteFile(filePath); + } + } + [Fact] public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() { @@ -88,6 +156,26 @@ public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() } } + private static PersistentProcessRule CreateRule( + string id, + string processName, + ProcessPriorityClass priority) => + new() + { + Id = id, + Name = $"{processName} rule", + IsEnabled = true, + ProcessName = processName, + ExecutablePath = $@"C:\Probe\{processName}", + Priority = priority, + MemoryPriority = ProcessMemoryPriority.BelowNormal, + ApplyPriorityOnStart = true, + ApplyMemoryPriorityOnStart = true, + CreatedAt = DateTime.UtcNow.AddMinutes(-1), + UpdatedAt = DateTime.UtcNow, + Description = "Persistence test rule", + }; + private static string CreateTemporaryFilePath() => Path.Combine(Path.GetTempPath(), $"threadpilot-rules-{Guid.NewGuid():N}", "rules.json"); diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index 494d46d..50b4214 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -35,6 +35,59 @@ public async Task ApplyForProcessStartAsync_WhenMatchingEnabledRuleExists_CallsR Times.Once); } + [Fact] + public async Task ApplyForProcessStartAsync_WithVerifiedPriority_LogsVerifiedSuccessAndSchedulesDelayedVerification() + { + var process = CreateProcess(); + var rule = CreateRule(); + var verified = CreateSuccess(rule, process) with + { + PriorityApplied = true, + RequestedPriority = ProcessPriorityClass.High, + ObservedPriority = ProcessPriorityClass.High, + PriorityVerified = true, + PriorityVerificationPhase = "immediate", + }; + var engine = CreateEngine(rule, verified); + var audit = new ActivityAuditService(NullLogger.Instance); + var verifier = new Mock(MockBehavior.Strict); + verifier.Setup(x => x.ScheduleDelayedVerification( + It.Is(r => r.PriorityVerified), + process, + "ProcessStarted")); + var service = CreateService([rule], engine.Object, audit: audit, priorityVerifier: verifier.Object); + + await service.ApplyForProcessStartAsync(process); + + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Contains("priority verified", entry.Message, StringComparison.OrdinalIgnoreCase); + verifier.VerifyAll(); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WithPriorityNotObserved_DoesNotLogAutoAppliedSuccess() + { + var process = CreateProcess(); + var rule = CreateRule(); + var notObserved = CreateSuccess(rule, process) with + { + Success = false, + PriorityApplied = true, + RequestedPriority = ProcessPriorityClass.High, + ObservedPriority = ProcessPriorityClass.Normal, + UserMessage = "ThreadPilot requested the saved priority, but the process reported a different priority.", + }; + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], CreateEngine(rule, notObserved).Object, audit: audit); + + await service.ApplyForProcessStartAsync(process); + + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.DoesNotContain("Auto-applied saved rule", entry.Message); + Assert.Contains("observed Normal instead of High", entry.Message); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + } + [Fact] public async Task ApplyForProcessStartAsync_WhenRuleIsDisabled_DoesNotCallRulesEngine() { @@ -386,7 +439,8 @@ private static PersistentRuleAutoApplyService CreateService( IPersistentRulesEngine engine, ApplicationSettingsModel? settings = null, Func? nowProvider = null, - IActivityAuditService? audit = null) => + IActivityAuditService? audit = null, + IPersistentRulePriorityVerificationService? priorityVerifier = null) => new( new FakePersistentProcessRuleStore(rules), new PersistentProcessRuleMatcher(), @@ -395,7 +449,8 @@ private static PersistentRuleAutoApplyService CreateService( NullLogger.Instance, nowProvider ?? (() => DateTimeOffset.UtcNow), TimeSpan.FromSeconds(30), - audit); + audit, + priorityVerifier); private static Mock CreateEngine( PersistentProcessRule rule, diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulePriorityVerificationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulePriorityVerificationServiceTests.cs new file mode 100644 index 0000000..9af8efb --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulePriorityVerificationServiceTests.cs @@ -0,0 +1,292 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using Xunit; + + public sealed class PersistentRulePriorityVerificationServiceTests + { + [Fact] + public async Task DelayedVerification_PriorityRemainsHigh_RecordsVerifiedState() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateProcessService(ProcessPriorityClass.High, ProcessPriorityClass.High); + var service = CreateService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + + var entries = await audit.GetEntriesAsync(); + Assert.Equal(2, entries.Count); + Assert.All(entries, entry => Assert.Contains("Verified saved priority", entry.Message)); + processService.Verify(x => x.RefreshProcessInfo(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task DelayedVerification_PriorityRevertsToNormal_RecordsReversion() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateProcessService(ProcessPriorityClass.Normal); + var service = CreateService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Contains("reverted", entry.Message, StringComparison.OrdinalIgnoreCase); + processService.Verify(x => x.RefreshProcessInfo(It.IsAny()), Times.Once); + } + + [Fact] + public async Task DelayedVerification_DoesNotLoopIndefinitely() + { + var processService = CreateProcessService(ProcessPriorityClass.High, ProcessPriorityClass.High, ProcessPriorityClass.High); + var service = CreateService(processService.Object); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + + processService.Verify(x => x.RefreshProcessInfo(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task PriorityRevertedAfterApply_SchedulesSingleRetry() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal, ProcessPriorityClass.Normal, ProcessPriorityClass.High, ProcessPriorityClass.High); + var service = CreateRetryService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + await WaitForAuditAsync(audit, "PersistentRulePriorityRetryVerified"); + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + await Task.Delay(20); + + var entries = await audit.GetEntriesAsync(); + Assert.Single(entries, entry => entry.Details?.Contains("PersistentRulePriorityRetryScheduled") == true); + processService.Verify(x => x.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High, ProcessPriorityWriteSource.PersistentRuleRetry), Times.Once); + } + + [Fact] + public async Task PriorityRetry_WhenObservedPriorityMatchesRequested_RecordsVerifiedSuccess() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal, ProcessPriorityClass.Normal, ProcessPriorityClass.High, ProcessPriorityClass.High); + var service = CreateRetryService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + var entries = await WaitForAuditAsync(audit, "PersistentRulePriorityRetryVerified"); + + Assert.Contains(entries, entry => entry.Details?.Contains("PersistentRulePriorityRetryVerified") == true && entry.Details.Contains("Phase: retry-immediate")); + } + + [Fact] + public async Task PriorityRetry_WhenPriorityRevertsAgain_DoesNotRetryAgain() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal, ProcessPriorityClass.Normal, ProcessPriorityClass.High, ProcessPriorityClass.Normal); + var service = CreateRetryService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + var entries = await WaitForAuditAsync(audit, "PersistentRulePriorityRetryReverted"); + + Assert.Single(entries, entry => entry.Details?.Contains("PersistentRulePriorityRetryScheduled") == true); + processService.Verify(x => x.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High, ProcessPriorityWriteSource.PersistentRuleRetry), Times.Once); + } + + [Fact] + public async Task PriorityRetry_DoesNotRunForExitedProcess() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal); + processService.Setup(x => x.IsProcessStillRunning(It.IsAny())).ReturnsAsync(false); + var service = CreateRetryService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + var entries = await WaitForAuditAsync(audit, "PersistentRulePriorityRetrySkipped"); + + Assert.Contains(entries, entry => entry.Details?.Contains("process-exited") == true); + processService.Verify(x => x.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PriorityRetry_DoesNotRunForAccessDeniedOrProtectedProcess() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal); + var service = CreateRetryService(processService.Object, audit); + var process = CreateProcess(); + process.Classification = ProcessClassification.ProtectedOrAccessDenied; + + await service.VerifyDelayedAsync(CreateVerifiedResult(), process, "ProcessStarted"); + var entries = await WaitForAuditAsync(audit, "PersistentRulePriorityRetrySkipped"); + + Assert.Contains(entries, entry => entry.Details?.Contains("protected-or-access-denied") == true); + processService.Verify(x => x.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PriorityRetry_IsCancelledWhenProcessExits() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal); + var delays = 0; + var service = CreateRetryService(processService.Object, audit, (_, token) => ++delays == 1 ? Task.CompletedTask : tcs.Task.WaitAsync(token)); + + await service.VerifyDelayedAsync(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + service.MarkProcessExited(42); + tcs.SetResult(); + await Task.Delay(20); + + processService.Verify(x => x.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PriorityRetry_IsScopedToPidAndRuleSignature() + { + var audit = new ActivityAuditService(NullLogger.Instance); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal, ProcessPriorityClass.Normal, ProcessPriorityClass.High, ProcessPriorityClass.High, ProcessPriorityClass.Normal, ProcessPriorityClass.Normal, ProcessPriorityClass.High, ProcessPriorityClass.High); + var service = CreateRetryService(processService.Object, audit); + + await service.VerifyDelayedAsync(CreateVerifiedResult() with { ProcessId = 42 }, CreateProcess(42), "ProcessStarted"); + await WaitForAuditAsync(audit, "PersistentRulePriorityRetryVerified"); + await service.VerifyDelayedAsync(CreateVerifiedResult() with { ProcessId = 43 }, CreateProcess(43), "ProcessStarted"); + await WaitForCountAsync(audit, entry => entry.Details?.Contains("PersistentRulePriorityRetryScheduled") == true, 2); + + processService.Verify(x => x.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High, ProcessPriorityWriteSource.PersistentRuleRetry), Times.Exactly(2)); + } + + [Fact] + public void PriorityRetry_DoesNotBlockProcessStartHandler() + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var processService = CreateRetryProcessService(ProcessPriorityClass.Normal); + var delays = 0; + var service = CreateRetryService(processService.Object, delayAsync: (_, token) => ++delays == 1 ? Task.CompletedTask : tcs.Task.WaitAsync(token)); + + service.ScheduleDelayedVerification(CreateVerifiedResult(), CreateProcess(), "ProcessStarted"); + + processService.Verify(x => x.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + service.MarkProcessExited(42); + tcs.SetResult(); + } + + private static PersistentRulePriorityVerificationService CreateService( + IProcessService processService, + IActivityAuditService? audit = null) => + new( + processService, + NullLogger.Instance, + audit, + [TimeSpan.Zero, TimeSpan.Zero], + (_, _) => Task.CompletedTask); + + private static PersistentRulePriorityVerificationService CreateRetryService( + IProcessService processService, + IActivityAuditService? audit = null, + Func? delayAsync = null, + IReadOnlyList? rules = null) => + new( + processService, + NullLogger.Instance, + audit, + new FakePersistentProcessRuleStore(rules ?? [CreateRule()]), + new PersistentProcessRuleMatcher(), + [TimeSpan.Zero, TimeSpan.Zero], + TimeSpan.Zero, + delayAsync ?? ((_, _) => Task.CompletedTask)); + + private static Mock CreateProcessService(params ProcessPriorityClass[] observedPriorities) + { + var calls = 0; + var mock = new Mock(MockBehavior.Strict); + mock + .Setup(x => x.RefreshProcessInfo(It.IsAny())) + .Returns(process => + { + process.Priority = observedPriorities[Math.Min(calls, observedPriorities.Length - 1)]; + calls++; + return Task.CompletedTask; + }); + return mock; + } + + private static Mock CreateRetryProcessService(params ProcessPriorityClass[] observedPriorities) + { + var mock = CreateProcessService(observedPriorities); + mock.Setup(x => x.IsProcessStillRunning(It.IsAny())).ReturnsAsync(true); + mock + .Setup(x => x.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((process, priority, _) => + { + process.Priority = priority; + return Task.CompletedTask; + }); + return mock; + } + + private static async Task> WaitForAuditAsync(ActivityAuditService audit, string action) => + await WaitForCountAsync(audit, entry => entry.Details?.Contains(action) == true, 1); + + private static async Task> WaitForCountAsync(ActivityAuditService audit, Predicate predicate, int count) + { + for (var i = 0; i < 50; i++) + { + var entries = await audit.GetEntriesAsync(); + if (entries.Count(entry => predicate(entry)) >= count) + { + return entries; + } + + await Task.Delay(10); + } + + return await audit.GetEntriesAsync(); + } + + private static PersistentRuleAutoApplyResult CreateVerifiedResult() => + new() + { + Success = true, + RuleId = "rule-high", + ProcessId = 42, + ProcessName = "game.exe", + RequestedPriority = ProcessPriorityClass.High, + ObservedPriority = ProcessPriorityClass.High, + PriorityVerified = true, + PriorityVerificationPhase = "immediate", + }; + + private static PersistentProcessRule CreateRule(string id = "rule-high") => + new() + { + Id = id, + Name = id, + IsEnabled = true, + ProcessName = "game.exe", + Priority = ProcessPriorityClass.High, + ApplyPriorityOnStart = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + private static ProcessModel CreateProcess(int processId = 42) => + new() + { + ProcessId = processId, + Name = "game.exe", + ExecutablePath = @"C:\Games\game.exe", + Priority = ProcessPriorityClass.High, + }; + + 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/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 7d1103d..6f5ef16 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -48,7 +48,7 @@ public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAf } [Fact] - public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() + public async Task ApplyPriority_ObservedPriorityMatchesRequested_ReturnsVerifiedSuccess() { var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); var affinity = CreateAffinityService(); @@ -57,12 +57,65 @@ public async Task ApplyMatchingRulesAsync_WithPriority_AppliesPriority() var engine = CreateEngine([rule], affinity.Object, processService.Object, memoryPriorityService.Object); var process = CreateProcess(); - var results = await engine.ApplyMatchingRulesAsync(process); + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(process)); - Assert.Single(results); - Assert.True(results[0].Success); - Assert.True(results[0].PriorityApplied); + Assert.True(result.Success); + Assert.True(result.PriorityApplied); + Assert.True(result.PriorityVerified); + Assert.False(result.PriorityVerificationUnavailable); + Assert.Equal(ProcessPriorityClass.High, result.RequestedPriority); + Assert.Equal(ProcessPriorityClass.High, result.ObservedPriority); + Assert.Equal("immediate", result.PriorityVerificationPhase); processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); + processService.Verify(s => s.RefreshProcessInfo(process), Times.Once); + } + + [Fact] + public async Task ApplyPriority_SetterReturnsWithoutException_ObservedPriorityIsNormal_ReturnsNotObserved() + { + var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + processService + .Setup(s => s.RefreshProcessInfo(It.IsAny())) + .Returns(process => + { + process.Priority = ProcessPriorityClass.Normal; + return Task.CompletedTask; + }); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + var process = CreateProcess(); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(process)); + + Assert.False(result.Success); + Assert.True(result.PriorityApplied); + Assert.False(result.PriorityVerified); + Assert.Equal(ProcessPriorityClass.High, result.RequestedPriority); + Assert.Equal(ProcessPriorityClass.Normal, result.ObservedPriority); + Assert.Equal("PriorityNotObserved", result.ErrorCode); + } + + [Fact] + public async Task ApplyPriority_ProcessExitedDuringVerification_ReturnsVerificationUnavailableOrProcessExited() + { + var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + processService + .Setup(s => s.RefreshProcessInfo(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Process has exited")); + var engine = CreateEngine([rule], affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(CreateProcess())); + + Assert.False(result.Success); + Assert.True(result.PriorityApplied); + Assert.False(result.PriorityVerified); + Assert.True(result.PriorityVerificationUnavailable); + Assert.True(result.IsProcessExited); + Assert.Equal(AffinityApplyErrorCodes.ProcessExited, result.ErrorCode); + Assert.Equal(ProcessPriorityClass.High, result.RequestedPriority); } [Fact] @@ -381,6 +434,19 @@ public async Task ApplyMatchingRulesAsync_WhenRuleFilterIncludesSelectedRule_App processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High), Times.Once); } + [Fact] + public async Task PriorityWriteSource_IsRecordedForPersistentRule() + { + var rule = CreateRule(priority: ProcessPriorityClass.High, applyPriority: true); + var processService = CreateProcessService(); + var engine = CreateEngine([rule], CreateAffinityService().Object, processService.Object, CreateMemoryPriorityService().Object); + var process = CreateProcess(); + + await engine.ApplyMatchingRulesAsync(process); + + processService.Verify(s => s.SetProcessPriority(process, ProcessPriorityClass.High, ProcessPriorityWriteSource.PersistentRuleInitialApply), Times.Once); + } + private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, @@ -412,6 +478,17 @@ private static Mock CreateProcessService() var mock = new Mock(MockBehavior.Strict); mock .Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny())) + .Returns((process, priority) => + { + process.Priority = priority; + return Task.CompletedTask; + }); + mock + .Setup(s => s.SetProcessPriority(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((process, priority, _) => + mock.Object.SetProcessPriority(process, priority)); + mock + .Setup(s => s.RefreshProcessInfo(It.IsAny())) .Returns(Task.CompletedTask); return mock; } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index ea164df..feb8506 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.Core.Tests { using System.Collections.ObjectModel; + using System.Diagnostics; using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -88,6 +89,70 @@ public async Task StartAsync_SelectsHighestPriorityAssociation() Times.Once); } + [Fact] + public async Task PersistentRuleAndAssociation_OrderIsObservable() + { + var process = new ProcessModel { ProcessId = 10, Name = "game", Priority = ProcessPriorityClass.Normal }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration + { + PowerPlanChangeDelayMs = 0, + Associations = + { + new ProcessPowerPlanAssociation("game", "plan-game", "Game") + { + ProcessPriority = ProcessPriorityClass.Normal.ToString(), + }, + }, + }; + var autoApply = CreateAutoApplyService(); + autoApply + .Setup(x => x.ApplyForProcessStartAsync(process, It.IsAny())) + .ReturnsAsync(() => + { + process.Priority = ProcessPriorityClass.High; + return + [ + new PersistentRuleAutoApplyResult + { + Success = true, + RuleId = "persistent-high", + ProcessId = process.ProcessId, + ProcessName = process.Name, + RequestedPriority = ProcessPriorityClass.High, + ObservedPriority = ProcessPriorityClass.High, + PriorityVerified = true, + }, + ]; + }); + var processService = CreateProcessService(); + processService.Setup(x => x.TrackPriorityChange(process.ProcessId, ProcessPriorityClass.High)); + processService + .Setup(x => x.SetProcessPriority(process, ProcessPriorityClass.Normal, ProcessPriorityWriteSource.PowerPlanAssociation)) + .Returns(() => + { + process.Priority = ProcessPriorityClass.Normal; + return Task.CompletedTask; + }); + var manager = CreateService( + processMonitor, + CreateAssociationService(configuration), + CreatePowerPlanService(), + CreateNotificationService(), + processService, + CreateCoreMaskService(), + CreateAffinityApplyService(), + autoApply); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(50); + + Assert.Equal(ProcessPriorityClass.Normal, process.Priority); + autoApply.Verify(x => x.ApplyForProcessStartAsync(process, It.IsAny()), Times.Once); + processService.Verify(x => x.SetProcessPriority(process, ProcessPriorityClass.Normal, ProcessPriorityWriteSource.PowerPlanAssociation), Times.Once); + } + [Fact] public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs index 47eef10..b109cfe 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -321,8 +321,95 @@ public async Task SaveCurrentSettingsAsRuleAsync_ReturnsControlledFailureWhenNoA Assert.Empty(store.SavedRules); } + [Fact] + public async Task CreateRule_SaveThenReload_PreservesAllPersistedFields() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var service = CreateService(store); + var selection = CreateCpuSelection(); + + try + { + var result = await service.SaveRuleAsync( + CreateProcess(name: "PersistenceProbe.exe", path: @"C:\Probe\PersistenceProbe.exe"), + new ProcessRuleCreationPayload + { + CpuSelection = selection, + Priority = ProcessPriorityClass.High, + MemoryPriority = ProcessMemoryPriority.BelowNormal, + }); + + Assert.True(result.Success); + var loaded = await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync(); + var rule = Assert.Single(loaded); + Assert.Equal(result.Rule!.Id, rule.Id); + Assert.Equal("PersistenceProbe.exe rule", rule.Name); + Assert.True(rule.IsEnabled); + Assert.Equal("PersistenceProbe.exe", rule.ProcessName); + Assert.Equal(@"C:\Probe\PersistenceProbe.exe", rule.ExecutablePath); + Assert.NotNull(rule.CpuSelection); + Assert.Equal(selection.GlobalLogicalProcessorIndexes, rule.CpuSelection.GlobalLogicalProcessorIndexes); + Assert.Null(rule.LegacyAffinityMask); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.Equal(ProcessMemoryPriority.BelowNormal, rule.MemoryPriority); + Assert.True(rule.ApplyAffinityOnStart); + Assert.True(rule.ApplyPriorityOnStart); + Assert.True(rule.ApplyMemoryPriorityOnStart); + Assert.Equal(result.Rule.CreatedAt, rule.CreatedAt); + Assert.Equal(result.Rule.UpdatedAt, rule.UpdatedAt); + Assert.Equal("Created from Process tab action.", rule.Description); + } + finally + { + DeleteTemporaryDirectory(filePath); + } + } + + [Fact] + public async Task UpdateRule_SaveThenReload_PreservesUpdatedValues() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var service = CreateService(store); + var process = CreateProcess(name: "PersistenceProbe.exe", path: @"C:\Probe\PersistenceProbe.exe"); + + try + { + var created = await service.SaveRuleAsync( + process, + new ProcessRuleCreationPayload { Priority = ProcessPriorityClass.AboveNormal }); + var updated = await service.SaveRuleAsync( + process, + new ProcessRuleCreationPayload + { + LegacyAffinityMask = 0x3, + Priority = ProcessPriorityClass.High, + MemoryPriority = ProcessMemoryPriority.Low, + }); + + Assert.True(created.Success); + Assert.True(updated.Success); + Assert.True(updated.Updated); + var loaded = await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync(); + var rule = Assert.Single(loaded); + Assert.Equal(created.Rule!.Id, rule.Id); + Assert.Equal(created.Rule.CreatedAt, rule.CreatedAt); + Assert.Equal(0x3, rule.LegacyAffinityMask); + Assert.Equal(ProcessPriorityClass.High, rule.Priority); + Assert.Equal(ProcessMemoryPriority.Low, rule.MemoryPriority); + Assert.True(rule.ApplyAffinityOnStart); + Assert.True(rule.ApplyPriorityOnStart); + Assert.True(rule.ApplyMemoryPriorityOnStart); + } + finally + { + DeleteTemporaryDirectory(filePath); + } + } + private static ProcessRuleCreationService CreateService( - CapturingRuleStore store, + IPersistentProcessRuleStore store, ICpuTopologyProvider? topologyProvider = null) => new( store, @@ -351,6 +438,18 @@ private static ProcessModel CreateProcess( ProcessorAffinity = affinity, }; + private static string CreateTemporaryFilePath() => + Path.Combine(Path.GetTempPath(), $"threadpilot-rule-service-{Guid.NewGuid():N}", "rules.json"); + + private static void DeleteTemporaryDirectory(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if (directory != null && Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + private sealed class CapturingRuleStore(IReadOnlyList? initialRules = null) : IPersistentProcessRuleStore { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 29ae4e5..16b6d31 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -25,7 +25,7 @@ public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); processService.Verify( - service => service.SetProcessPriority(process, ProcessPriorityClass.High), + service => service.SetProcessPriority(process, ProcessPriorityClass.High, ProcessPriorityWriteSource.ManualUiAction), Times.Once); enhancedLoggingService.Verify( service => service.LogUserActionAsync( diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index da87c65..c2897b2 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -16,10 +16,10 @@ link true CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.4.1 - 1.4.1.0 - 1.4.1.0 - 1.4.1 + 1.4.2 + 1.4.2.0 + 1.4.2.0 + 1.4.2 diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 5b995cf..3a5c0f0 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -1372,7 +1372,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => }); // Apply the priority change - await this.processService.SetProcessPriority(selectedProcess, priority); + await this.processService.SetProcessPriority(selectedProcess, priority, ProcessPriorityWriteSource.ManualUiAction); // Immediately refresh the process to get the actual system state await this.processService.RefreshProcessInfo(selectedProcess); @@ -1671,7 +1671,7 @@ await this.LogUserActionAsync( try { - await this.processService.SetProcessPriority(process, priority); + await this.processService.SetProcessPriority(process, priority, ProcessPriorityWriteSource.ManualUiAction); await this.processService.RefreshProcessInfo(process); var warning = ProcessPriorityGuardrails.GetWarning(priority); diff --git a/app.manifest b/app.manifest index 8bc4006..4f1c660 100644 --- a/app.manifest +++ b/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index 6b9cffd..3ccddcb 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.4.1", + [string]$Version = "1.4.2", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index e9aae4e..5e3ecd9 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.4.1", + [string]$Version = "1.4.2", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index 1520bd0..722d81f 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.4.1" + [string]$Version = "1.4.2" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index eb5c8d7..1dd6aa0 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.4.1 + 1.4.2 ThreadPilot Prime Build https://github.com/PrimeBuild-pc/ThreadPilot @@ -15,7 +15,7 @@ false Advanced Windows process and power plan manager with rules automation and performance controls. ThreadPilot process and power plan manager. - https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.1 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.4.2 threadpilot process powerplan performance windows diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 587ad60..1d80328 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project are documented in this file. +## v1.4.2 - Persistent CPU-priority rule verification and retry + +### Fixed + +- Persistent CPU-priority rules now verify the applied priority. +- ThreadPilot detects when a process resets its CPU priority shortly after startup. +- A single bounded retry is performed when a verified priority is reverted. +- Retry state is cleared when the process exits. +- Improved activity logging distinguishes initial apply, reversion, retry, verification, and final failure. +- Added attribution for internal CPU-priority writes. +- Fixed misleading success reporting when the requested priority did not remain applied. +- Fixes #32. + ## v1.4.0 - Safe in-app updater ### Added diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index 97d35ab..1c44518 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,17 +1,19 @@ -## ThreadPilot v1.4.1 +## ThreadPilot v1.4.2 -Maintenance release focused on repository cleanup and release hygiene. +Patch release fixing persistent CPU-priority rules that are reverted shortly after process startup. Fixes #32. -### Changed +### Fixed -- Removed unused service infrastructure and factory layers. -- Removed disabled Game Boost code that was already excluded from builds. -- Removed stale audit/planning/reference documents and tracked binary ZIP artifacts. -- Removed unused MemoryCache and Console logging package references. -- Reduced source noise by dropping repeated boilerplate comments. +- Persistent CPU-priority rules now verify the applied priority. +- ThreadPilot detects when a process resets its CPU priority shortly after startup. +- A single bounded retry is performed when a verified priority is reverted. +- Retry state is cleared when the process exits. +- Improved activity logging distinguishes initial apply, reversion, retry, verification, and final failure. +- Added attribution for internal CPU-priority writes. +- Fixed misleading success reporting when the requested priority did not remain applied. ### Validation -- `dotnet build ThreadPilot.csproj -c Release` -- `dotnet test Tests/ThreadPilot.Core.Tests/ThreadPilot.Core.Tests.csproj -c Release --no-restore` -- Manual smoke test on the published build. +- 555 automated tests passed with no failures. +- The general apply → verify → detect revert → one retry → final verify lifecycle was validated with a real process that resets its priority after startup. +- No executable-specific retry logic or continuous polling was added. diff --git a/docs/releases/v1.4.2.md b/docs/releases/v1.4.2.md new file mode 100644 index 0000000..579679a --- /dev/null +++ b/docs/releases/v1.4.2.md @@ -0,0 +1,19 @@ +# ThreadPilot v1.4.2 + +Patch release fixing persistent CPU-priority rules that are reverted shortly after process startup. Fixes #32. + +## Fixed + +- Persistent CPU-priority rules now verify the applied priority. +- ThreadPilot detects when a process resets its CPU priority shortly after startup. +- A single bounded retry is performed when a verified priority is reverted. +- Retry state is cleared when the process exits. +- Improved activity logging distinguishes initial apply, reversion, retry, verification, and final failure. +- Added attribution for internal CPU-priority writes. +- Fixed misleading success reporting when the requested priority did not remain applied. + +## Validation + +- 555 automated tests passed with no failures. +- The general apply → verify → detect revert → one retry → final verify lifecycle was validated with a real process that resets its priority after startup. +- No executable-specific retry logic or continuous polling was added. diff --git a/sonar-project.properties b/sonar-project.properties index af60f4c..fcff330 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=threadpilot sonar.projectName=ThreadPilot -sonar.projectVersion=1.4.1 +sonar.projectVersion=1.4.2 sonar.sourceEncoding=UTF-8 sonar.sources=.