diff --git a/Services/PersistentProcessRuleJsonStore.cs b/Services/PersistentProcessRuleJsonStore.cs index c7c7526..0811312 100644 --- a/Services/PersistentProcessRuleJsonStore.cs +++ b/Services/PersistentProcessRuleJsonStore.cs @@ -17,6 +17,8 @@ public sealed class PersistentProcessRuleJsonStore : IPersistentProcessRuleStore private readonly Func filePathProvider; private readonly ILogger? logger; + private readonly SemaphoreSlim cacheLock = new(1, 1); + private volatile IReadOnlyList? cachedRules; public PersistentProcessRuleJsonStore(ILogger? logger = null) : this(() => StoragePaths.PersistentRulesFilePath, logger) @@ -33,25 +35,43 @@ internal PersistentProcessRuleJsonStore( public async Task> LoadAsync() { - var filePath = this.filePathProvider(); - this.logger?.LogDebug("Loading persistent process rules from {FilePath}", filePath); - if (!File.Exists(filePath)) + if (this.cachedRules != null) { - this.logger?.LogDebug("Persistent process rules file does not exist at {FilePath}", filePath); - return []; + return this.cachedRules; } + await this.cacheLock.WaitAsync().ConfigureAwait(false); try { - var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); - var rules = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - this.logger?.LogDebug("Loaded {RuleCount} persistent process rules from {FilePath}", rules.Count, filePath); - return rules; + if (this.cachedRules != null) + { + return this.cachedRules; + } + + 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 this.cachedRules = []; + } + + try + { + var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + var rules = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + this.logger?.LogDebug("Loaded {RuleCount} persistent process rules from {FilePath}", rules.Count, filePath); + return this.cachedRules = rules.ToArray(); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath); + return this.cachedRules = []; + } } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + finally { - this.logger?.LogWarning(ex, "Could not load persistent process rules from {FilePath}", filePath); - return []; + this.cacheLock.Release(); } } @@ -59,18 +79,27 @@ public async Task SaveAsync(IReadOnlyList rules) { ArgumentNullException.ThrowIfNull(rules); - var filePath = this.filePathProvider(); - this.logger?.LogDebug("Saving {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); + await this.cacheLock.WaitAsync().ConfigureAwait(false); try { - 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); + var filePath = this.filePathProvider(); + this.logger?.LogDebug("Saving {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); + try + { + var json = JsonSerializer.Serialize(rules, JsonOptions); + await AtomicFileWriter.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + this.cachedRules = rules.ToArray(); + 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; + } } - catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + finally { - this.logger?.LogError(ex, "Could not save {RuleCount} persistent process rules to {FilePath}", rules.Count, filePath); - throw; + this.cacheLock.Release(); } } } diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 50ef349..820d707 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.Services { using System.Collections.Concurrent; + using System.IO; using Microsoft.Extensions.Logging; using ThreadPilot.Models; @@ -86,6 +87,7 @@ public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplySer private readonly ILogger logger; private readonly IActivityAuditService? activityAuditService; private readonly IPersistentRulePriorityVerificationService? priorityVerificationService; + private readonly IProcessService? processService; private readonly Func nowProvider; private readonly TimeSpan cooldown; private readonly ConcurrentDictionary recentAttempts = new(); @@ -97,8 +99,9 @@ public PersistentRuleAutoApplyService( IApplicationSettingsService settingsService, ILogger logger, IActivityAuditService? activityAuditService = null, - IPersistentRulePriorityVerificationService? priorityVerificationService = null) - : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService) + IPersistentRulePriorityVerificationService? priorityVerificationService = null, + IProcessService? processService = null) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService, processService) { } @@ -111,7 +114,8 @@ public PersistentRuleAutoApplyService( Func nowProvider, TimeSpan cooldown, IActivityAuditService? activityAuditService = null, - IPersistentRulePriorityVerificationService? priorityVerificationService = null) + IPersistentRulePriorityVerificationService? priorityVerificationService = null, + IProcessService? processService = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -122,6 +126,7 @@ public PersistentRuleAutoApplyService( this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; this.activityAuditService = activityAuditService; this.priorityVerificationService = priorityVerificationService; + this.processService = processService; } public async Task> ApplyForDiscoveredProcessesAsync( @@ -190,8 +195,35 @@ private async Task> ApplyForProcess CancellationToken cancellationToken) { var now = this.nowProvider(); - var candidates = rules - .Where(rule => rule.IsEnabled && this.matcher.IsMatch(rule, process)) + var potentialRules = rules + .Where(rule => rule.IsEnabled && IsPotentialNameMatch(rule, process.Name)) + .ToList(); + + if (potentialRules.Count == 0) + { + return Array.Empty(); + } + + if (this.processService != null && + string.IsNullOrWhiteSpace(process.ExecutablePath) && + potentialRules.Any(rule => !string.IsNullOrWhiteSpace(rule.ExecutablePath))) + { + try + { + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogDebug( + ex, + "Could not enrich process {ProcessName} (PID: {ProcessId}) for path-based persistent rule matching", + process.Name, + process.ProcessId); + } + } + + var candidates = potentialRules + .Where(rule => this.matcher.IsMatch(rule, process)) .ToList(); if (candidates.Count == 0) @@ -393,6 +425,18 @@ private static string ResolveActivityMessage(PersistentRuleAutoApplyResult resul private bool IsEnabled() => this.settingsService.Settings.ApplyPersistentRulesOnProcessStart; + private static bool IsPotentialNameMatch(PersistentProcessRule rule, string processName) + { + var candidateName = !string.IsNullOrWhiteSpace(rule.ProcessName) + ? rule.ProcessName + : Path.GetFileNameWithoutExtension(rule.ExecutablePath); + return !string.IsNullOrWhiteSpace(candidateName) && + string.Equals( + Path.GetFileNameWithoutExtension(candidateName.Trim()), + Path.GetFileNameWithoutExtension(processName.Trim()), + StringComparison.OrdinalIgnoreCase); + } + private static bool IsProcessEligible(ProcessModel process) => process.ProcessId > 0 && !string.IsNullOrWhiteSpace(process.Name); diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index a26c309..eb1cd49 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -346,13 +346,34 @@ private async Task OnProcessStartedAsync(ProcessEventArgs e) try { - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.Started, - e.Process.Name, e.Process.ProcessId, "Process started and detected by monitoring"); + this.logger.LogDebug( + "Process started: {ProcessName} (PID: {ProcessId})", + e.Process.Name, + e.Process.ProcessId); await this.ApplyPersistentRulesForProcessStartAsync(e.Process); - var association = this.configuration.FindMatchingAssociation(e.Process); + var associationCandidates = this.configuration + .GetEnabledAssociations() + .Where(association => association.MatchesExecutable(e.Process.Name)) + .ToList(); + if (associationCandidates.Count > 0) + { + try + { + await this.processService.RefreshProcessInfo(e.Process); + } + catch (Exception ex) + { + this.logger.LogDebug( + ex, + "Could not enrich process {ProcessName} (PID: {ProcessId}) for association matching", + e.Process.Name, + e.Process.ProcessId); + } + } + + var association = associationCandidates.FirstOrDefault(candidate => candidate.MatchesProcess(e.Process)); if (association != null) { this.runningAssociatedProcesses[e.Process.ProcessId] = e.Process; @@ -411,12 +432,11 @@ private async Task OnProcessStoppedAsync(ProcessEventArgs e) try { this.persistentRuleAutoApplyService.MarkProcessExited(e.Process.ProcessId); + this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId); + this.processService.UntrackProcess(e.Process.ProcessId); if (this.runningAssociatedProcesses.TryRemove(e.Process.ProcessId, out _)) { - this.coreMaskService.UnregisterMaskApplication(e.Process.ProcessId); - this.processService.UntrackProcess(e.Process.ProcessId); - // Check if there are any other associated processes still running var remainingProcesses = this.runningAssociatedProcesses.Values.ToList(); await this.DeterminePowerPlanAsync(remainingProcesses); diff --git a/Services/ProcessMonitorService.cs b/Services/ProcessMonitorService.cs index 56b2a1d..8d8366d 100644 --- a/Services/ProcessMonitorService.cs +++ b/Services/ProcessMonitorService.cs @@ -3,7 +3,6 @@ namespace ThreadPilot.Services using System; using System.Collections.Concurrent; using System.Collections.Generic; - using System.Diagnostics; using System.IO; using System.Linq; using System.Management; @@ -371,11 +370,11 @@ private void OnProcessStarted(object sender, EventArrivedEventArgs e) }); } - private async Task HandleProcessStartedAsync(EventArrivedEventArgs e) + private Task HandleProcessStartedAsync(EventArrivedEventArgs e) { if (!this.isMonitoring || this.IsDisposed) { - return; + return Task.CompletedTask; } try @@ -383,11 +382,14 @@ private async Task HandleProcessStartedAsync(EventArrivedEventArgs e) var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); var processName = e.NewEvent["ProcessName"]?.ToString() ?? string.Empty; - // Get detailed process information - var process = await this.CreateProcessModelFromId(processId, processName).ConfigureAwait(false) - ?? (!string.IsNullOrWhiteSpace(processName) - ? new ProcessModel { ProcessId = processId, Name = NormalizeProcessName(processName) } - : null); + var process = !string.IsNullOrWhiteSpace(processName) + ? new ProcessModel + { + ProcessId = processId, + Name = NormalizeProcessName(processName), + Classification = ProcessClassification.Unknown, + } + : null; if (process != null) { @@ -399,6 +401,8 @@ private async Task HandleProcessStartedAsync(EventArrivedEventArgs e) { this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); } + + return Task.CompletedTask; } private void OnProcessStopped(object sender, EventArrivedEventArgs e) @@ -581,36 +585,6 @@ private async Task TryRecoverWmiMonitoringAsync() } } - private async Task CreateProcessModelFromId(int processId, string processName) - { - try - { - using var process = Process.GetProcessById(processId); - var normalizedName = !string.IsNullOrWhiteSpace(processName) - ? NormalizeProcessName(processName) - : NormalizeProcessName(process.ProcessName); - - return new ProcessModel - { - ProcessId = process.Id, - Name = normalizedName, - CpuUsage = 0, - MemoryUsage = process.PrivateMemorySize64, - Priority = process.PriorityClass, - ProcessorAffinity = (long)process.ProcessorAffinity, - MainWindowHandle = process.MainWindowHandle, - MainWindowTitle = process.MainWindowTitle ?? string.Empty, - HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle), - ExecutablePath = process.MainModule?.FileName ?? string.Empty, - }; - } - catch (Exception ex) - { - this.logger?.LogDebug(ex, "Process {ProcessId} terminated before access", processId); - return null; - } - } - private void OnMonitoringStatusChanged(string? message = null, Exception? error = null) { this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs( diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index fc755e7..db51921 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -110,15 +110,26 @@ internal ProcessService( public async Task> GetProcessesAsync() { return await Task.Run(() => + new ObservableCollection(this.EnumerateProcessModels())).ConfigureAwait(false); + } + + private List EnumerateProcessModels(Func? filter = null) + { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); + var models = new List(); + foreach (var process in Process.GetProcesses()) { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .OrderBy(p => p.Name); + using (process) + { + var model = this.TryCreateProcessModel(process, foregroundProcessId); + if (model != null && (filter == null || filter(model))) + { + models.Add(model); + } + } + } - return new ObservableCollection(processes); - }).ConfigureAwait(false); + return models.OrderBy(process => process.Name).ToList(); } private sealed class CpuSample @@ -771,6 +782,11 @@ await Task.Run(() => process.MainWindowHandle = p.MainWindowHandle; process.MainWindowTitle = p.MainWindowTitle ?? string.Empty; process.HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle); + if (string.IsNullOrWhiteSpace(process.ExecutablePath)) + { + process.ExecutablePath = p.MainModule?.FileName ?? string.Empty; + } + this.ApplyProcessClassification( process, this.foregroundProcessService?.TryGetForegroundProcessId(), @@ -983,7 +999,7 @@ public async Task ClearProcessCpuSetAsync(ProcessModel process) { try { - var process = Process.GetProcessById(processId); + using var process = Process.GetProcessById(processId); return this.CreateProcessModel(process); } catch @@ -995,20 +1011,29 @@ public async Task ClearProcessCpuSetAsync(ProcessModel process) public async Task> GetProcessesByNameAsync(string executableName) { - return await Task.Run(() => + return await Task.Run>(() => { try { var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcessesByName(executableName) - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType(); + var models = new List(); + foreach (var process in Process.GetProcessesByName(executableName)) + { + using (process) + { + var model = this.TryCreateProcessModel(process, foregroundProcessId); + if (model != null) + { + models.Add(model); + } + } + } - return processes; + return models; } catch { - return Enumerable.Empty(); + return Array.Empty(); } }).ConfigureAwait(false); } @@ -1021,32 +1046,15 @@ public async Task IsProcessRunningAsync(string executableName) public async Task> GetProcessesWithPathsAsync() { - return await Task.Run(() => - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .Where(p => !string.IsNullOrEmpty(p.ExecutablePath)) - .OrderBy(p => p.Name); - - return processes; - }).ConfigureAwait(false); + return await Task.Run>(() => + this.EnumerateProcessModels(process => !string.IsNullOrEmpty(process.ExecutablePath))).ConfigureAwait(false); } public async Task> GetActiveApplicationsAsync() { return await Task.Run(() => - { - var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); - var processes = Process.GetProcesses() - .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) - .OfType() - .Where(p => p.HasVisibleWindow) - .OrderBy(p => p.Name); - - return new ObservableCollection(processes); - }).ConfigureAwait(false); + new ObservableCollection( + this.EnumerateProcessModels(process => process.HasVisibleWindow))).ConfigureAwait(false); } public async Task IsProcessStillRunning(ProcessModel process) @@ -1055,8 +1063,8 @@ public async Task IsProcessStillRunning(ProcessModel process) { try { - var p = Process.GetProcessById(process.ProcessId); - return !p.HasExited; + using var targetProcess = Process.GetProcessById(process.ProcessId); + return !targetProcess.HasExited; } catch (ArgumentException) { diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs index dc24386..b1520e7 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -136,6 +136,74 @@ public async Task SaveAsync_WithMissingDirectory_CreatesDirectory() } } + [Fact] + public async Task LoadAsync_AfterFirstRead_ReturnsCachedSnapshot() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + try + { + await store.SaveAsync([CreateRule("cached", "Cached.exe", ProcessPriorityClass.High)]); + var first = await store.LoadAsync(); + await new PersistentProcessRuleJsonStore(() => filePath) + .SaveAsync([CreateRule("external", "External.exe", ProcessPriorityClass.Normal)]); + + var second = await store.LoadAsync(); + + Assert.Same(first, second); + Assert.Equal("cached", Assert.Single(second).Id); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task SaveAsync_AfterCachedLoad_UpdatesSnapshot() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + + try + { + await store.SaveAsync([CreateRule("first", "First.exe", ProcessPriorityClass.High)]); + _ = await store.LoadAsync(); + await store.SaveAsync([CreateRule("second", "Second.exe", ProcessPriorityClass.AboveNormal)]); + + Assert.Equal("second", Assert.Single(await store.LoadAsync()).Id); + Assert.Equal("second", Assert.Single(await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync()).Id); + } + finally + { + DeleteFile(filePath); + } + } + + [Fact] + public async Task ConcurrentSaves_LeaveOneCompleteSnapshot() + { + var filePath = CreateTemporaryFilePath(); + var store = new PersistentProcessRuleJsonStore(() => filePath); + var expectedIds = Enumerable.Range(0, 8).Select(index => $"rule-{index}").ToHashSet(); + + try + { + await Task.WhenAll(expectedIds.Select(id => + store.SaveAsync([CreateRule(id, $"{id}.exe", ProcessPriorityClass.High)]))); + + var cached = Assert.Single(await store.LoadAsync()); + var persisted = Assert.Single(await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync()); + Assert.Contains(cached.Id, expectedIds); + Assert.Equal(cached.Id, persisted.Id); + } + finally + { + DeleteFile(filePath); + } + } + [Fact] public async Task LoadAsync_WithCorruptJson_ReturnsEmptyList() { diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index 50b4214..78c59d8 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -126,6 +126,50 @@ public async Task ApplyForProcessStartAsync_WhenNoRuleMatches_DoesNotCallRulesEn Times.Never); } + [Fact] + public async Task ApplyForProcessStartAsync_WhenNameCannotMatch_DoesNotEnrichProcess() + { + var process = CreateProcess("editor.exe"); + process.ExecutablePath = string.Empty; + var rule = CreateRule(processName: "game.exe") with { ExecutablePath = @"C:\Games\Game.exe" }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processService = new Mock(MockBehavior.Strict); + var service = CreateService([rule], engine.Object, processService: processService.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + processService.Verify( + service => service.RefreshProcessInfo(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenPathRuleNameMatches_EnrichesBeforeMatching() + { + var process = CreateProcess(); + process.ExecutablePath = string.Empty; + var rule = CreateRule() with { ExecutablePath = @"C:\Games\Game.exe" }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processService = new Mock(MockBehavior.Strict); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .Callback(() => process.ExecutablePath = @"C:\Games\Game.exe") + .Returns(Task.CompletedTask); + var service = CreateService([rule], engine.Object, processService: processService.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Single(results); + processService.Verify(service => service.RefreshProcessInfo(process), Times.Once); + engine.Verify( + service => service.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + [Fact] public async Task ApplyForProcessStartAsync_DoesNotReapplySameRuleDuringCooldown() { @@ -440,7 +484,8 @@ private static PersistentRuleAutoApplyService CreateService( ApplicationSettingsModel? settings = null, Func? nowProvider = null, IActivityAuditService? audit = null, - IPersistentRulePriorityVerificationService? priorityVerifier = null) => + IPersistentRulePriorityVerificationService? priorityVerifier = null, + IProcessService? processService = null) => new( new FakePersistentProcessRuleStore(rules), new PersistentProcessRuleMatcher(), @@ -450,7 +495,8 @@ private static PersistentRuleAutoApplyService CreateService( nowProvider ?? (() => DateTimeOffset.UtcNow), TimeSpan.FromSeconds(30), audit, - priorityVerifier); + priorityVerifier, + processService); private static Mock CreateEngine( PersistentProcessRule rule, diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index feb8506..aa8d937 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -681,6 +681,66 @@ public async Task ProcessStarted_WhenPersistentRuleAutoApplyReturnsFailure_DoesN Times.Never); } + [Fact] + public async Task ProcessStarted_WithoutRelevantAssociation_DoesNotEnrichOrPersistEventLog() + { + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var processService = CreateProcessService(); + var enhancedLogger = CreateEnhancedLogger(); + var manager = CreateService( + processMonitor, + CreateAssociationService(configuration), + CreatePowerPlanService(), + CreateNotificationService(), + processService, + CreateCoreMaskService(), + CreateAffinityApplyService(), + enhancedLogger: enhancedLogger); + + await manager.StartAsync(); + enhancedLogger.Invocations.Clear(); + processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 42, Name = "irrelevant" }); + await Task.Delay(100); + + processService.Verify( + service => service.RefreshProcessInfo(It.IsAny()), + Times.Never); + enhancedLogger.Verify( + logger => logger.LogProcessMonitoringEventAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ProcessStopped_AlwaysClearsPidCaches() + { + var processMonitor = new FakeProcessMonitorService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var autoApplyService = CreateAutoApplyService(); + var manager = CreateService( + processMonitor, + CreateAssociationService(new ProcessMonitorConfiguration()), + CreatePowerPlanService(), + CreateNotificationService(), + processService, + coreMaskService, + CreateAffinityApplyService(), + autoApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStopped(new ProcessModel { ProcessId = 73, Name = "short-lived" }); + await Task.Delay(100); + + autoApplyService.Verify(service => service.MarkProcessExited(73), Times.Once); + coreMaskService.Verify(service => service.UnregisterMaskApplication(73), Times.Once); + processService.Verify(service => service.UntrackProcess(73), Times.Once); + } + [Fact] public async Task Dispose_CompletesOnBlockingSynchronizationContext() { @@ -862,11 +922,7 @@ private sealed class FakeProcessMonitorService : IProcessMonitorService { public event EventHandler? ProcessStarted; - public event EventHandler? ProcessStopped - { - add { } - remove { } - } + public event EventHandler? ProcessStopped; public event EventHandler? MonitoringStatusChanged { @@ -917,6 +973,9 @@ public void UpdateSettings() public void RaiseProcessStarted(ProcessModel process) => this.ProcessStarted?.Invoke(this, new ProcessEventArgs(process)); + public void RaiseProcessStopped(ProcessModel process) => + this.ProcessStopped?.Invoke(this, new ProcessEventArgs(process)); + public void Dispose() { this.DisposeCalls++; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index d257bda..ee7a992 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -347,6 +347,26 @@ public void IsPassiveProcessAccessException_ReturnsFalse_ForUnrelatedException() Assert.False(result); } + [Fact] + public async Task GetProcessesByNameAsync_ReturnsMaterializedModels() + { + var profilesDirectory = CreateTemporaryDirectory(); + var service = CreateService(profilesDirectory); + using var currentProcess = Process.GetCurrentProcess(); + + try + { + var results = await service.GetProcessesByNameAsync(currentProcess.ProcessName); + + var models = Assert.IsType>(results); + Assert.Contains(models, process => process.ProcessId == currentProcess.Id); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + [Fact] public void TrackPriorityChange_PreservesOriginalPriority() {