diff --git a/Installer/setup.iss b/Installer/setup.iss index 4af7366..c190df4 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.7.8" + #define MyAppVersion "1.7.9" #endif #ifndef MyAppSourceDir diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index e749691..8f5d5c9 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -1287,7 +1287,7 @@ private async Task StartProcessMonitoringManagerAsync() this.LogDebug("Starting process monitoring manager service..."); // Start the process monitoring manager service with internal timeout - var startTask = this.processMonitorManagerService.StartAsync(); + var startTask = this.processMonitorManagerService.SetAutomationMonitoringEnabledAsync(true); var timeoutTask = Task.Delay(6000); // 6 second internal timeout var completedTask = await Task.WhenAny(startTask, timeoutTask); diff --git a/Services/IProcessMonitorService.cs b/Services/IProcessMonitorService.cs index 3d0eff2..b3a1aad 100644 --- a/Services/IProcessMonitorService.cs +++ b/Services/IProcessMonitorService.cs @@ -54,13 +54,16 @@ public class MonitoringStatusEventArgs : EventArgs public Exception? Error { get; } - public MonitoringStatusEventArgs(bool isMonitoring, bool isWmiAvailable, bool isFallbackPollingActive, string? statusMessage = null, Exception? error = null) + public bool SnapshotRefreshed { get; } + + public MonitoringStatusEventArgs(bool isMonitoring, bool isWmiAvailable, bool isFallbackPollingActive, string? statusMessage = null, Exception? error = null, bool snapshotRefreshed = false) { this.IsMonitoring = isMonitoring; this.IsWmiAvailable = isWmiAvailable; this.IsFallbackPollingActive = isFallbackPollingActive; this.StatusMessage = statusMessage; this.Error = error; + this.SnapshotRefreshed = snapshotRefreshed; } } } diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 820d707..b223999 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.Services { using System.Collections.Concurrent; + using System.Diagnostics; using System.IO; using Microsoft.Extensions.Logging; using ThreadPilot.Models; @@ -90,6 +91,9 @@ public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplySer private readonly IProcessService? processService; private readonly Func nowProvider; private readonly TimeSpan cooldown; + private readonly IEnhancedLoggingService? enhancedLogger; + private readonly object attemptLock = new(); + private readonly Dictionary activeApplications = new(); private readonly ConcurrentDictionary recentAttempts = new(); public PersistentRuleAutoApplyService( @@ -100,8 +104,9 @@ public PersistentRuleAutoApplyService( ILogger logger, IActivityAuditService? activityAuditService = null, IPersistentRulePriorityVerificationService? priorityVerificationService = null, - IProcessService? processService = null) - : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService, processService) + IProcessService? processService = null, + IEnhancedLoggingService? enhancedLogger = null) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService, priorityVerificationService, processService, enhancedLogger) { } @@ -115,7 +120,8 @@ public PersistentRuleAutoApplyService( TimeSpan cooldown, IActivityAuditService? activityAuditService = null, IPersistentRulePriorityVerificationService? priorityVerificationService = null, - IProcessService? processService = null) + IProcessService? processService = null, + IEnhancedLoggingService? enhancedLogger = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -127,6 +133,7 @@ public PersistentRuleAutoApplyService( this.activityAuditService = activityAuditService; this.priorityVerificationService = priorityVerificationService; this.processService = processService; + this.enhancedLogger = enhancedLogger; } public async Task> ApplyForDiscoveredProcessesAsync( @@ -180,6 +187,14 @@ public async Task> ApplyForProcessS public void MarkProcessExited(int processId) { + lock (this.attemptLock) + { + if (this.activeApplications.Remove(processId, out var application)) + { + application.Cancel(); + } + } + foreach (var key in this.recentAttempts.Keys.Where(key => key.ProcessId == processId)) { this.recentAttempts.TryRemove(key, out _); @@ -194,6 +209,38 @@ private async Task> ApplyForProcess string source, CancellationToken cancellationToken) { + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + lock (this.attemptLock) + { + if (!this.activeApplications.TryAdd(process.ProcessId, lifetime)) + { + return Array.Empty(); + } + } + + try + { + return await this.ApplyForProcessCoreAsync(process, rules, source, lifetime.Token).ConfigureAwait(false); + } + finally + { + lock (this.attemptLock) + { + if (this.activeApplications.TryGetValue(process.ProcessId, out var current) && ReferenceEquals(current, lifetime)) + { + this.activeApplications.Remove(process.ProcessId); + } + } + } + } + + private async Task> ApplyForProcessCoreAsync( + ProcessModel process, + IReadOnlyList rules, + string source, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); var now = this.nowProvider(); var potentialRules = rules .Where(rule => rule.IsEnabled && IsPotentialNameMatch(rule, process.Name)) @@ -208,24 +255,78 @@ private async Task> ApplyForProcess string.IsNullOrWhiteSpace(process.ExecutablePath) && potentialRules.Any(rule => !string.IsNullOrWhiteSpace(rule.ExecutablePath))) { - try + // Capture creation time with query-only access so a reused PID cannot receive this retry. + var identity = TryGetProcessStartTime(process.ProcessId); + await this.TryRefreshPathAsync(process).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (identity != null && !IsOriginalProcessRunning(process.ProcessId, identity.Value)) { - await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + return Array.Empty(); } - catch (Exception ex) + + if (string.IsNullOrWhiteSpace(process.ExecutablePath) && identity != null) { - this.logger.LogDebug( - ex, - "Could not enrich process {ProcessName} (PID: {ProcessId}) for path-based persistent rule matching", - process.Name, - process.ProcessId); + source += "/PathRetry"; + await this.LogPathRetryAsync(process, "scheduled (up to 3 retries over 3 seconds)").ConfigureAwait(false); + try + { + foreach (var delay in new[] { 250, 750, 2000 }) + { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + if (!this.IsEnabled() || !IsOriginalProcessRunning(process.ProcessId, identity.Value)) + { + await this.LogPathRetryAsync(process, "stopped: process exited or automation disabled").ConfigureAwait(false); + return Array.Empty(); + } + + rules = await this.ruleStore.LoadAsync().ConfigureAwait(false); + potentialRules = rules.Where(rule => rule.IsEnabled && IsPotentialNameMatch(rule, process.Name)).ToList(); + if (!potentialRules.Any(rule => !string.IsNullOrWhiteSpace(rule.ExecutablePath))) + { + break; + } + + await this.TryRefreshPathAsync(process).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (!IsOriginalProcessRunning(process.ProcessId, identity.Value)) + { + await this.LogPathRetryAsync(process, "stopped: original process exited").ConfigureAwait(false); + return Array.Empty(); + } + + if (!string.IsNullOrWhiteSpace(process.ExecutablePath)) + { + break; + } + } + + await this.LogPathRetryAsync(process, string.IsNullOrWhiteSpace(process.ExecutablePath) + ? "finished: path still unavailable or rule removed" + : "recovered: matching current rules against refreshed path").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await this.LogPathRetryAsync(process, "cancelled: monitoring stopped or process exited").ConfigureAwait(false); + throw; + } + } + else if (string.IsNullOrWhiteSpace(process.ExecutablePath)) + { + await this.LogPathRetryAsync(process, "skipped: original process identity unavailable").ConfigureAwait(false); } } + cancellationToken.ThrowIfCancellationRequested(); + var candidates = potentialRules .Where(rule => this.matcher.IsMatch(rule, process)) .ToList(); + if (source.EndsWith("/PathRetry", StringComparison.Ordinal)) + { + await this.LogPathRetryAsync(process, $"matched {candidates.Count} current rules").ConfigureAwait(false); + } + if (candidates.Count == 0) { return Array.Empty(); @@ -266,6 +367,11 @@ private async Task> ApplyForProcess this.priorityVerificationService?.ScheduleDelayedVerification(result, process, source); } + if (source.EndsWith("/PathRetry", StringComparison.Ordinal)) + { + await this.LogPathRetryAsync(process, $"completed: {results.Count(result => result.Success)} succeeded, {results.Count(result => !result.Success)} failed").ConfigureAwait(false); + } + return results; } catch (Exception ex) when (ex is not OperationCanceledException) @@ -276,6 +382,11 @@ private async Task> ApplyForProcess process.Name, process.ProcessId); + if (source.EndsWith("/PathRetry", StringComparison.Ordinal)) + { + await this.LogPathRetryAsync(process, $"apply failed: {ex.Message}").ConfigureAwait(false); + } + return selectedRules .Select(rule => new PersistentRuleAutoApplyResult { @@ -290,6 +401,63 @@ private async Task> ApplyForProcess } } + private static DateTime? TryGetProcessStartTime(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return process.StartTime; + } + catch + { + return null; + } + } + + private static bool IsOriginalProcessRunning(int processId, DateTime originalStartTime) + { + try + { + using var current = Process.GetProcessById(processId); + return !current.HasExited && current.StartTime == originalStartTime; + } + catch + { + return false; + } + } + + private async Task TryRefreshPathAsync(ProcessModel process) + { + try + { + await this.processService!.RefreshProcessInfo(process).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogDebug(ex, "Could not enrich process {ProcessId} for path-based rule matching", process.ProcessId); + } + } + + private async Task LogPathRetryAsync(ProcessModel process, string outcome) + { + try + { + if (this.enhancedLogger != null) + { + await this.enhancedLogger.LogProcessMonitoringEventAsync( + "PersistentRulePathRetry", + process.Name, + process.ProcessId, + $"Path-based saved rule metadata retry: {outcome}; path: {process.ExecutablePath}").ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Could not persist path retry diagnostic"); + } + } + private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTimeOffset now) { var key = new RuleAttemptKey(processId, PersistentRuleRuntimeKeys.GetRuleSignature(rule)); @@ -305,6 +473,14 @@ private bool TryRecordAttempt(int processId, PersistentProcessRule rule, DateTim private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) { + lock (this.attemptLock) + { + foreach (var pair in this.activeApplications.Where(pair => !currentProcessIds.Contains(pair.Key)).ToList()) + { + pair.Value.Cancel(); + } + } + foreach (var key in this.recentAttempts.Keys.Where(key => !currentProcessIds.Contains(key.ProcessId))) { this.recentAttempts.TryRemove(key, out _); diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index 3eed7d7..96df021 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -31,6 +31,7 @@ public class ProcessMonitorManagerService : IProcessMonitorManagerService private readonly SemaphoreSlim powerPlanChangeSemaphore = new(1, 1); private readonly SemaphoreSlim stateMutationSemaphore = new(1, 1); + private CancellationTokenSource automationLifetime = new(); private bool isRunning; private string status = "Stopped"; private bool disposed; @@ -99,6 +100,17 @@ public async Task StartAsync() return; } + this.automationLifetime.Dispose(); + this.automationLifetime = new CancellationTokenSource(); + try + { + await this.enhancedLogger.InitializeAsync(); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "File diagnostics could not be initialized; automation monitoring will continue"); + } + this.logger.LogInformation("Starting Process Monitor Manager Service"); await this.enhancedLogger.LogSystemEventAsync( LogEventTypes.System.ServiceStarted, @@ -141,6 +153,7 @@ await this.enhancedLogger.LogSystemEventAsync( await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.StartAsync", new Dictionary { ["ServiceName"] = "ProcessMonitorManagerService" }); this.isRunning = false; + this.automationLifetime.Cancel(); this.SetStatus(false, "Failed to start", $"Error: {ex.Message}", ex); throw; } @@ -150,7 +163,7 @@ await this.enhancedLogger.LogErrorAsync(ex, "ProcessMonitorManagerService.StartA } // Evaluate current processes after startup lock is released - await this.EvaluateCurrentProcessesAsync(); + await this.EvaluateCurrentProcessesAsync(resetRuleAttempts: true); } public async Task StopAsync() @@ -167,6 +180,7 @@ public async Task StopAsync() // Mark as stopped early to prevent new event handling while shutting down this.isRunning = false; + this.automationLifetime.Cancel(); Interlocked.Exchange(ref this.pendingPowerPlanReevaluation, 0); // Stop process monitoring @@ -216,7 +230,9 @@ public async Task SetAutomationMonitoringEnabledAsync(bool enabled) await this.settingsService.UpdateSettingsAsync(settings); } - public async Task EvaluateCurrentProcessesAsync() + public Task EvaluateCurrentProcessesAsync() => this.EvaluateCurrentProcessesAsync(resetRuleAttempts: false); + + private async Task EvaluateCurrentProcessesAsync(bool resetRuleAttempts) { if (!this.isRunning || this.configuration == null) { @@ -226,6 +242,16 @@ public async Task EvaluateCurrentProcessesAsync() try { var currentProcesses = (await this.processMonitorService.GetRunningProcessesAsync()).ToList(); + if (resetRuleAttempts) + { + // A fresh monitoring lifetime or resume must also reapply rules after a short sleep, + // even if the last application is still inside the normal duplicate-event cooldown. + foreach (var process in currentProcesses) + { + this.persistentRuleAutoApplyService.MarkProcessExited(process.ProcessId); + } + } + await this.ApplyPersistentRulesForDiscoveredProcessesAsync(currentProcesses); var associatedProcesses = new List(); var currentProcessIds = new HashSet(currentProcesses.Select(p => p.ProcessId)); @@ -466,6 +492,20 @@ private async Task OnProcessStoppedAsync(ProcessEventArgs e) private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs e) { + if (e.StatusMessage != null) + { + TaskSafety.FireAndForget( + this.enhancedLogger.LogProcessMonitoringEventAsync( + "MonitoringHealth", "ProcessMonitorService", 0, e.StatusMessage), ex => + this.logger.LogWarning(ex, "Could not persist monitoring health diagnostics")); + } + + if (e.SnapshotRefreshed && this.isRunning) + { + TaskSafety.FireAndForget(this.EvaluateCurrentProcessesAsync(resetRuleAttempts: true), ex => + this.logger.LogWarning(ex, "Could not apply rules after monitoring recovery")); + } + var details = e.StatusMessage ?? (e.IsMonitoring ? "Monitoring active" : "Monitoring inactive"); this.SetStatus(this.isRunning, $"Monitor: {details}", e.StatusMessage, e.Error); } @@ -633,7 +673,7 @@ private async Task ApplyPersistentRulesForDiscoveredProcessesAsync(IEnumerable

? logger; - private readonly object lockObject = new(); private readonly ConcurrentDictionary runningProcesses = new(); private readonly SemaphoreSlim wmiStartSemaphore = new(1, 1); private readonly Dictionary pollBuffer = new(); - private ManagementEventWatcher? processStartWatcher; - private ManagementEventWatcher? processStopWatcher; + private readonly Func watcherFactory; + private readonly IMonitoringWakeEvents wakeEvents; + private IProcessWatchers? watchers; private System.Threading.Timer? fallbackTimer; + private System.Threading.Timer? watchdogTimer; + private long lastWmiEventTicks; + private int recoveryRequested; private CancellationTokenSource? cancellationTokenSource; private bool isMonitoring; @@ -62,7 +64,19 @@ public ProcessMonitorService( IProcessService processService, IApplicationSettingsService settingsService, ILogger? logger = null) + : this(processService, settingsService, logger, () => new WmiProcessWatchers(), new WindowsMonitoringWakeEvents()) { + } + + internal ProcessMonitorService( + IProcessService processService, + IApplicationSettingsService settingsService, + ILogger? logger, + Func watcherFactory, + IMonitoringWakeEvents wakeEvents) + { + this.watcherFactory = watcherFactory; + this.wakeEvents = wakeEvents; this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.logger = logger; @@ -78,50 +92,74 @@ public async Task StartMonitoringAsync() throw new ObjectDisposedException(nameof(ProcessMonitorService)); } - lock (this.lockObject) + await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); + try { + if (this.IsDisposed || Volatile.Read(ref this.disposeRequestedFlag) != 0) + { + throw new ObjectDisposedException(nameof(ProcessMonitorService)); + } + if (this.isMonitoring) { return; } this.isMonitoring = true; - } + this.cancellationTokenSource = new CancellationTokenSource(); + this.lastWmiRetryAttemptUtc = DateTime.MinValue; - this.cancellationTokenSource = new CancellationTokenSource(); - this.lastWmiRetryAttemptUtc = DateTime.MinValue; - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); + this.UpdateMonitoringSettings(); - this.UpdateMonitoringSettings(); + bool wmiStarted = false; + if (this.enableWmiMonitoring) + { + // Try to start WMI monitoring first + wmiStarted = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); + } - // Initialize current process list - await this.InitializeProcessListAsync().ConfigureAwait(false); + if (!wmiStarted && this.enableFallbackPolling) + { + this.lastWmiRetryAttemptUtc = DateTime.UtcNow; + this.wmiRecoveryFailureCount = 0; + // Fall back to polling if WMI is not available + this.StartFallbackPolling(); + } + else if (!wmiStarted && !this.enableFallbackPolling) + { + var reason = this.enableWmiMonitoring + ? "WMI monitoring unavailable and fallback polling is disabled" + : "Both WMI monitoring and fallback polling are disabled"; - bool wmiStarted = false; - if (this.enableWmiMonitoring) - { - // Try to start WMI monitoring first - wmiStarted = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); - } + this.OnMonitoringStatusChanged(reason); + } - if (!wmiStarted && this.enableFallbackPolling) + // Subscribe before taking the snapshot, so starts during enumeration are not lost. + await this.InitializeProcessListAsync().ConfigureAwait(false); + this.wakeEvents.Wake += this.OnWake; + this.wakeEvents.Start(); + this.watchdogTimer = new System.Threading.Timer(_ => this.QueueHealthCheck(), null, 10000, 10000); + this.OnMonitoringStatusChanged(); + } + catch { - this.lastWmiRetryAttemptUtc = DateTime.UtcNow; - this.wmiRecoveryFailureCount = 0; - // Fall back to polling if WMI is not available - this.StartFallbackPolling(); + this.isMonitoring = false; + this.wakeEvents.Wake -= this.OnWake; + this.wakeEvents.Dispose(); + this.watchdogTimer?.Dispose(); + this.watchdogTimer = null; + this.StopWmiWatchers(); + this.StopFallbackPolling(); + this.cancellationTokenSource?.Cancel(); + this.cancellationTokenSource?.Dispose(); + this.cancellationTokenSource = null; + this.runningProcesses.Clear(); + throw; } - else if (!wmiStarted && !this.enableFallbackPolling) + finally { - var reason = this.enableWmiMonitoring - ? "WMI monitoring unavailable and fallback polling is disabled" - : "Both WMI monitoring and fallback polling are disabled"; - - this.OnMonitoringStatusChanged(reason); + this.wmiStartSemaphore.Release(); } - - this.OnMonitoringStatusChanged(); } public async Task StopMonitoringAsync() @@ -144,16 +182,19 @@ public async Task StopMonitoringAsync() try { - lock (this.lockObject) + if (!this.isMonitoring) { - if (!this.isMonitoring) - { - return; - } - - this.isMonitoring = false; + return; } + this.isMonitoring = false; + + this.wakeEvents.Wake -= this.OnWake; + this.wakeEvents.Dispose(); + this.watchdogTimer?.Dispose(); + this.watchdogTimer = null; + Interlocked.Exchange(ref this.recoveryRequested, 0); + // Stop WMI watchers this.StopWmiWatchers(); @@ -167,8 +208,6 @@ public async Task StopMonitoringAsync() this.runningProcesses.Clear(); this.pollBuffer.Clear(); - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); this.OnMonitoringStatusChanged(); } finally @@ -220,12 +259,20 @@ private async Task InitializeProcessListAsync() { try { + var previousIds = this.runningProcesses.Keys.ToArray(); var processes = await this.processService.GetProcessesAsync().ConfigureAwait(false); - this.runningProcesses.Clear(); - + var currentIds = processes.Select(p => p.ProcessId).ToHashSet(); foreach (var process in processes) { - this.runningProcesses.TryAdd(process.ProcessId, process); + this.runningProcesses[process.ProcessId] = process; + } + + foreach (var pid in previousIds.Where(pid => !currentIds.Contains(pid))) + { + if (this.runningProcesses.TryRemove(pid, out var stopped)) + { + this.ProcessStopped?.Invoke(this, new ProcessEventArgs(stopped)); + } } } catch (Exception ex) @@ -243,21 +290,7 @@ private async Task TryStartWmiMonitoringAsync() try { - await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); - } - catch (ObjectDisposedException) - { - return false; - } - - try - { - if (this.IsDisposed || !this.isMonitoring || !this.enableWmiMonitoring) - { - return false; - } - - if (this.isWmiAvailable && this.processStartWatcher != null && this.processStopWatcher != null) + if (this.isWmiAvailable && this.watchers != null) { return true; } @@ -267,23 +300,39 @@ await Task.Run(() => // Ensure any previous watchers are fully cleaned up before re-creating them this.StopWmiWatchers(); - // Create WMI event watchers for process start and stop - var startQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"); - var stopQuery = new WqlEventQuery("SELECT * FROM Win32_ProcessStopTrace"); - - this.processStartWatcher = new ManagementEventWatcher(startQuery); - this.processStopWatcher = new ManagementEventWatcher(stopQuery); - - this.processStartWatcher.EventArrived += this.OnProcessStarted; - this.processStopWatcher.EventArrived += this.OnProcessStopped; - this.processStartWatcher.Stopped += this.OnWmiWatcherStopped; - this.processStopWatcher.Stopped += this.OnWmiWatcherStopped; - - this.processStartWatcher.Start(); - this.processStopWatcher.Start(); + var current = this.watcherFactory(); + this.watchers = current; + current.Start( + (pid, name) => + { + if (ReferenceEquals(this.watchers, current)) + { + this.HandleProcessStarted(pid, name); + } + }, + pid => + { + if (ReferenceEquals(this.watchers, current)) + { + this.HandleProcessStopped(pid); + } + }, + reason => + { + if (ReferenceEquals(this.watchers, current)) + { + this.OnWmiWatcherStopped(reason); + } + }); }).ConfigureAwait(false); - this.isWmiAvailable = true; + this.isWmiAvailable = Volatile.Read(ref this.recoveryRequested) == 0; + Interlocked.Exchange(ref this.lastWmiEventTicks, DateTime.UtcNow.Ticks); + if (!this.isWmiAvailable) + { + throw new InvalidOperationException("WMI subscription stopped during startup"); + } + this.wmiRecoveryFailureCount = 0; this.lastWmiRetryAttemptUtc = DateTime.MinValue; @@ -306,16 +355,6 @@ await Task.Run(() => return false; } - finally - { - try - { - this.wmiStartSemaphore.Release(); - } - catch (ObjectDisposedException) - { - } - } } private void StartFallbackPolling() @@ -342,7 +381,7 @@ private void StartFallbackPolling() this.OnMonitoringStatusChanged($"Fallback polling started (interval: {this.fallbackPollingIntervalMs}ms)"); } - private void OnWmiWatcherStopped(object sender, StoppedEventArgs e) + private void OnWmiWatcherStopped(string reason) { if (!this.isMonitoring || this.IsDisposed) { @@ -350,41 +389,19 @@ private void OnWmiWatcherStopped(object sender, StoppedEventArgs e) } this.isWmiAvailable = false; - this.wmiRecoveryFailureCount = 0; - this.lastWmiRetryAttemptUtc = DateTime.UtcNow; - this.OnMonitoringStatusChanged($"WMI watcher stopped ({e.Status})"); - - if (this.enableFallbackPolling && !this.isFallbackPollingActive) - { - this.StartFallbackPolling(); - } + Interlocked.Exchange(ref this.recoveryRequested, 1); + this.OnMonitoringStatusChanged($"WMI watcher stopped ({reason}); recovery requested"); + this.QueueHealthCheck(); } private void StopWmiWatchers() { try { - if (this.processStartWatcher != null) - { - this.processStartWatcher.EventArrived -= this.OnProcessStarted; - this.processStartWatcher.Stopped -= this.OnWmiWatcherStopped; - } - - this.processStartWatcher?.Stop(); - this.processStartWatcher?.Dispose(); - this.processStartWatcher = null; - - if (this.processStopWatcher != null) - { - this.processStopWatcher.EventArrived -= this.OnProcessStopped; - this.processStopWatcher.Stopped -= this.OnWmiWatcherStopped; - } - - this.processStopWatcher?.Stop(); - this.processStopWatcher?.Dispose(); - this.processStopWatcher = null; - + var retired = this.watchers; + this.watchers = null; this.isWmiAvailable = false; + retired?.Dispose(); } catch (Exception ex) { @@ -397,29 +414,18 @@ private void StopFallbackPolling() this.fallbackTimer?.Dispose(); this.fallbackTimer = null; this.isFallbackPollingActive = false; - Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); - } - - private void OnProcessStarted(object sender, EventArrivedEventArgs e) - { - TaskSafety.FireAndForget(this.HandleProcessStartedAsync(e), ex => - { - this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); - }); } - private Task HandleProcessStartedAsync(EventArrivedEventArgs e) + private void HandleProcessStarted(int processId, string processName) { if (!this.isMonitoring || this.IsDisposed) { - return Task.CompletedTask; + return; } try { - var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); - var processName = e.NewEvent["ProcessName"]?.ToString() ?? string.Empty; - + Interlocked.Exchange(ref this.lastWmiEventTicks, DateTime.UtcNow.Ticks); var process = !string.IsNullOrWhiteSpace(processName) ? new ProcessModel { @@ -439,11 +445,9 @@ private Task HandleProcessStartedAsync(EventArrivedEventArgs e) { this.OnMonitoringStatusChanged($"Error handling process start event: {ex.Message}", ex); } - - return Task.CompletedTask; } - private void OnProcessStopped(object sender, EventArrivedEventArgs e) + private void HandleProcessStopped(int processId) { if (!this.isMonitoring || this.IsDisposed) { @@ -452,8 +456,7 @@ private void OnProcessStopped(object sender, EventArrivedEventArgs e) try { - var processId = Convert.ToInt32(e.NewEvent["ProcessID"]); - + Interlocked.Exchange(ref this.lastWmiEventTicks, DateTime.UtcNow.Ticks); if (this.runningProcesses.TryRemove(processId, out var process)) { this.ProcessStopped?.Invoke(this, new ProcessEventArgs(process)); @@ -492,6 +495,7 @@ private void FallbackPollingCallback(object? state) private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) { + await this.wmiStartSemaphore.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { if (cancellationToken.IsCancellationRequested) @@ -543,9 +547,6 @@ private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) } } - // Periodically retry WMI monitoring recovery while polling is active - await this.TryRecoverWmiMonitoringAsync().ConfigureAwait(false); - this.UpdateAdaptivePollingInterval(detectedChanges); } catch (Exception ex) @@ -554,6 +555,7 @@ private async Task RunFallbackPollingAsync(CancellationToken cancellationToken) } finally { + this.wmiStartSemaphore.Release(); Interlocked.Exchange(ref this.isFallbackPollingInProgress, 0); } } @@ -589,42 +591,78 @@ private void UpdateAdaptivePollingInterval(int detectedChanges) } } - private async Task TryRecoverWmiMonitoringAsync() + private void OnWake(object? sender, EventArgs e) { - if (!this.isMonitoring || this.IsDisposed || this.isWmiAvailable || !this.enableWmiMonitoring) + if (!this.isMonitoring || this.IsDisposed) { return; } - var now = DateTime.UtcNow; - if (now - this.lastWmiRetryAttemptUtc < GetWmiRetryDelay(this.wmiRecoveryFailureCount)) - { - return; - } + Interlocked.Exchange(ref this.recoveryRequested, 1); + this.OnMonitoringStatusChanged("Resume/session unlock: monitoring recovery requested"); + this.QueueHealthCheck(); + } + + private void QueueHealthCheck() + { + TaskSafety.FireAndForget(this.CheckHealthAsync(DateTime.UtcNow), ex => + this.OnMonitoringStatusChanged($"Monitoring watchdog failed: {ex.Message}", ex)); + } - if (Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 1) == 1) + internal async Task CheckHealthAsync(DateTime now) + { + if (this.IsDisposed || !this.isMonitoring || Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 1) == 1) { return; } - this.lastWmiRetryAttemptUtc = now; - try { - var recovered = await this.TryStartWmiMonitoringAsync().ConfigureAwait(false); - if (recovered) + await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); + try { - this.wmiRecoveryFailureCount = 0; - this.OnMonitoringStatusChanged("WMI monitoring recovered successfully"); + if (this.IsDisposed || !this.isMonitoring) + { + return; + } + + var requested = Interlocked.Exchange(ref this.recoveryRequested, 0) == 1; + var silent = this.enableWmiMonitoring && this.isWmiAvailable && + now - new DateTime(Interlocked.Read(ref this.lastWmiEventTicks), DateTimeKind.Utc) >= TimeSpan.FromMinutes(2); + if (!requested && !silent && (this.isWmiAvailable || !this.enableWmiMonitoring || + now - this.lastWmiRetryAttemptUtc < GetWmiRetryDelay(this.wmiRecoveryFailureCount))) + { + return; + } + + this.OnMonitoringStatusChanged(requested ? "Recovering monitoring after wake/watcher stop" : + silent ? "Recovering monitoring after WMI event silence" : "Retrying unavailable WMI monitoring"); + this.StopWmiWatchers(); + this.lastWmiRetryAttemptUtc = now; + if (this.enableWmiMonitoring && await this.TryStartWmiMonitoringAsync().ConfigureAwait(false)) + { + this.wmiRecoveryFailureCount = 0; + } + else + { + this.wmiRecoveryFailureCount++; + this.StartFallbackPolling(); + } + + // A restarted subscription cannot replay starts missed while Windows/WMI was unavailable. + await this.InitializeProcessListAsync().ConfigureAwait(false); + this.OnMonitoringStatusChanged("Monitoring recovery: process snapshot refreshed", snapshotRefreshed: true); } - else + finally { - this.wmiRecoveryFailureCount++; + this.wmiStartSemaphore.Release(); } } finally { Interlocked.Exchange(ref this.isWmiRecoveryInProgress, 0); + // A wake/Stopped notification may have arrived while the recovery gate was held. + // Leave it for the next watchdog tick rather than spin if WMI repeatedly fails. } } @@ -637,10 +675,15 @@ internal static TimeSpan GetWmiRetryDelay(int failureCount) => _ => TimeSpan.FromMinutes(5), }; - private void OnMonitoringStatusChanged(string? message = null, Exception? error = null) + private void OnMonitoringStatusChanged(string? message = null, Exception? error = null, bool snapshotRefreshed = false) { + if (message != null) + { + this.logger?.LogInformation(error, "Process monitoring: {Message}", message); + } + this.MonitoringStatusChanged?.Invoke(this, new MonitoringStatusEventArgs( - this.isMonitoring, this.isWmiAvailable, this.isFallbackPollingActive, message, error)); + this.isMonitoring, this.isWmiAvailable, this.isFallbackPollingActive, message, error, snapshotRefreshed)); } private void UpdateMonitoringSettings() @@ -653,49 +696,68 @@ private void UpdateMonitoringSettings() public void UpdateSettings() { - var previousInterval = this.fallbackPollingIntervalMs; - var previousWmiEnabled = this.enableWmiMonitoring; - var previousFallbackEnabled = this.enableFallbackPolling; - - this.UpdateMonitoringSettings(); - this.wmiRecoveryFailureCount = 0; - this.lastWmiRetryAttemptUtc = DateTime.MinValue; + TaskSafety.FireAndForget(this.UpdateSettingsAsync(), ex => + this.OnMonitoringStatusChanged($"Error updating monitoring settings: {ex.Message}", ex)); + } - if (!this.isMonitoring) + private async Task UpdateSettingsAsync() + { + await this.wmiStartSemaphore.WaitAsync().ConfigureAwait(false); + try { - return; - } + if (this.IsDisposed) + { + return; + } - if (!this.enableWmiMonitoring && this.isWmiAvailable) - { - this.StopWmiWatchers(); - this.OnMonitoringStatusChanged("WMI monitoring disabled by settings"); - } - else if (this.enableWmiMonitoring && !previousWmiEnabled && !this.isWmiAvailable) - { - TaskSafety.FireAndForget(this.TryStartWmiMonitoringAsync(), ex => + var previousInterval = this.fallbackPollingIntervalMs; + var previousWmiEnabled = this.enableWmiMonitoring; + var previousFallbackEnabled = this.enableFallbackPolling; + + this.UpdateMonitoringSettings(); + this.wmiRecoveryFailureCount = 0; + this.lastWmiRetryAttemptUtc = DateTime.MinValue; + + if (!this.isMonitoring) { - this.OnMonitoringStatusChanged($"Error recovering WMI monitoring: {ex.Message}", ex); - }); - } + return; + } - if (!this.enableFallbackPolling && this.isFallbackPollingActive) - { - this.StopFallbackPolling(); - this.OnMonitoringStatusChanged("Fallback polling disabled by settings"); - } - else if (this.enableFallbackPolling && !previousFallbackEnabled && (!this.isWmiAvailable || !this.enableWmiMonitoring)) - { - this.StartFallbackPolling(); - } + if (!this.enableWmiMonitoring && this.isWmiAvailable) + { + this.StopWmiWatchers(); + this.OnMonitoringStatusChanged("WMI monitoring disabled by settings"); + } + else if (this.enableWmiMonitoring && !previousWmiEnabled && !this.isWmiAvailable) + { + if (!await this.TryStartWmiMonitoringAsync().ConfigureAwait(false)) + { + this.StartFallbackPolling(); + } + } - // If fallback polling is active, restart it with new interval - if (this.isFallbackPollingActive && this.fallbackTimer != null && previousInterval != this.fallbackPollingIntervalMs) + if (!this.enableFallbackPolling && this.isFallbackPollingActive) + { + this.StopFallbackPolling(); + this.OnMonitoringStatusChanged("Fallback polling disabled by settings"); + } + else if (this.enableFallbackPolling && !previousFallbackEnabled && (!this.isWmiAvailable || !this.enableWmiMonitoring)) + { + this.StartFallbackPolling(); + } + + // If fallback polling is active, restart it with new interval + if (this.isFallbackPollingActive && this.fallbackTimer != null && previousInterval != this.fallbackPollingIntervalMs) + { + this.idlePollingMultiplier = 1; + this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; + this.fallbackTimer.Change(0, this.currentFallbackPollingIntervalMs); + this.OnMonitoringStatusChanged($"Polling interval updated to {this.fallbackPollingIntervalMs}ms"); + } + } + finally { - this.idlePollingMultiplier = 1; - this.currentFallbackPollingIntervalMs = this.fallbackPollingIntervalMs; - this.fallbackTimer.Change(0, this.currentFallbackPollingIntervalMs); - this.OnMonitoringStatusChanged($"Polling interval updated to {this.fallbackPollingIntervalMs}ms"); + this.wmiStartSemaphore.Release(); } } @@ -727,16 +789,9 @@ public void Dispose() } Interlocked.Exchange(ref this.disposedFlag, 1); - - try - { - this.wmiStartSemaphore.Dispose(); - } - catch (Exception ex) - { - this.OnMonitoringStatusChanged($"Error releasing process monitor resources: {ex.Message}", ex); - } + // Queued timer callbacks may still enter the gate after Stop. Keep the managed + // semaphore alive so they can observe isMonitoring=false and leave safely. + this.wakeEvents.Dispose(); } } } - diff --git a/Services/ProcessMonitoringEvents.cs b/Services/ProcessMonitoringEvents.cs new file mode 100644 index 0000000..e0e7be4 --- /dev/null +++ b/Services/ProcessMonitoringEvents.cs @@ -0,0 +1,108 @@ +namespace ThreadPilot.Services +{ + using System; + using System.Management; + using Microsoft.Win32; + + // Only the OS subscriptions are replaceable; recovery stays in ProcessMonitorService. + internal interface IProcessWatchers : IDisposable + { + void Start(Action started, Action stopped, Action failed); + } + + internal sealed class WmiProcessWatchers : IProcessWatchers + { + private readonly ManagementEventWatcher start = new(new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace")); + private readonly ManagementEventWatcher stop = new(new WqlEventQuery("SELECT * FROM Win32_ProcessStopTrace")); + + public void Start(Action started, Action stopped, Action failed) + { + this.start.EventArrived += (_, e) => HandleEvent( + () => started(Convert.ToInt32(e.NewEvent["ProcessID"]), e.NewEvent["ProcessName"]?.ToString() ?? string.Empty), failed); + this.stop.EventArrived += (_, e) => HandleEvent(() => stopped(Convert.ToInt32(e.NewEvent["ProcessID"])), failed); + this.start.Stopped += (_, e) => failed($"start watcher stopped ({e.Status})"); + this.stop.Stopped += (_, e) => failed($"stop watcher stopped ({e.Status})"); + this.start.Start(); + this.stop.Start(); + } + + private static void HandleEvent(Action handle, Action failed) + { + try + { + handle(); + } + catch (Exception ex) + { + failed($"WMI event could not be handled: {ex.Message}"); + } + } + + public void Dispose() + { + // Dispose both even if stopping one failed. The owner ignores retired callbacks. + try + { + try + { + this.start.Stop(); + } + finally + { + this.start.Dispose(); + } + } + finally + { + try + { + this.stop.Stop(); + } + finally + { + this.stop.Dispose(); + } + } + } + } + + internal interface IMonitoringWakeEvents : IDisposable + { + event EventHandler? Wake; + + void Start(); + } + + internal sealed class WindowsMonitoringWakeEvents : IMonitoringWakeEvents + { + public event EventHandler? Wake; + + public void Start() + { + SystemEvents.PowerModeChanged += this.OnPowerModeChanged; + SystemEvents.SessionSwitch += this.OnSessionSwitch; + } + + private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e) + { + if (e.Mode == PowerModes.Resume) + { + this.Wake?.Invoke(this, EventArgs.Empty); + } + } + + private void OnSessionSwitch(object sender, SessionSwitchEventArgs e) + { + if (e.Reason == SessionSwitchReason.SessionUnlock) + { + this.Wake?.Invoke(this, EventArgs.Empty); + } + } + + public void Dispose() + { + SystemEvents.PowerModeChanged -= this.OnPowerModeChanged; + SystemEvents.SessionSwitch -= this.OnSessionSwitch; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 680f89b..ecdab62 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.7.8"; - private const string ReleaseAssemblyVersion = "1.7.8.0"; + private const string ReleaseVersion = "1.7.9"; + private const string ReleaseAssemblyVersion = "1.7.9.0"; [Fact] public void InnoInstaller_UsesStableDisplayNameAndSeparateVersionMetadata() @@ -149,7 +149,7 @@ private static string FindRepositoryRoot() throw new InvalidOperationException("Repository root could not be located."); } - [GeneratedRegex("#define MyAppVersion \"1\\.7\\.8\"", RegexOptions.CultureInvariant)] + [GeneratedRegex("#define MyAppVersion \"1\\.7\\.9\"", RegexOptions.CultureInvariant)] private static partial Regex MyAppVersionRegex(); } } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index 78c59d8..f8b7920 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -478,6 +478,190 @@ public async Task MarkProcessExited_RemovesOnlyMatchingProcessAttempts() Times.Once); } + [Fact] + public async Task ApplyForProcessStartAsync_WhenPathBecomesAvailable_RetriesWithoutAnotherEvent() + { + var process = CreateProcess(); + process.ProcessId = Environment.ProcessId; + process.ExecutablePath = string.Empty; + var rule = CreateRule() with { ExecutablePath = @"C:\Games\Game.exe" }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processes = new Mock(MockBehavior.Strict); + int refreshes = 0; + processes.Setup(x => x.RefreshProcessInfo(process)).Returns(() => + { + if (Interlocked.Increment(ref refreshes) == 1) + { + throw new InvalidOperationException("Metadata not ready"); + } + + process.ExecutablePath = rule.ExecutablePath!; + return Task.CompletedTask; + }); + var diagnostics = new Mock(); + var service = CreateService([rule], engine.Object, processService: processes.Object, enhancedLogger: diagnostics.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Single(results); + Assert.Equal(2, refreshes); + diagnostics.Verify( + x => x.LogProcessMonitoringEventAsync( + "PersistentRulePathRetry", process.Name, process.ProcessId, + It.Is(message => message.Contains("completed: 1 succeeded, 0 failed"))), Times.Once); + await service.ApplyForProcessStartAsync(process); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), It.IsAny()), Times.Once); + } + + [Theory] + [InlineData("cancel")] + [InlineData("exit")] + [InlineData("missing")] + public async Task PathRetry_WhenLifetimeEnds_DoesNotRefreshOrApply(string reason) + { + var process = CreateProcess(); + process.ProcessId = Environment.ProcessId; + process.ExecutablePath = string.Empty; + var rule = CreateRule() with { ExecutablePath = @"C:\Games\Game.exe" }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processes = new Mock(MockBehavior.Strict); + processes.Setup(x => x.RefreshProcessInfo(process)).Returns(Task.CompletedTask); + var scheduled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var diagnostics = new Mock(); + diagnostics.Setup(x => x.LogProcessMonitoringEventAsync( + "PersistentRulePathRetry", process.Name, process.ProcessId, It.IsAny())) + .Callback((_, _, _, message) => + { + if (message.Contains("scheduled", StringComparison.Ordinal)) + { + scheduled.TrySetResult(); + } + }).Returns(Task.CompletedTask); + using var lifetime = new CancellationTokenSource(); + var service = CreateService([rule], engine.Object, processService: processes.Object, enhancedLogger: diagnostics.Object); + var pending = service.ApplyForProcessStartAsync(process, lifetime.Token); + await scheduled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // A duplicate discovery must not start another retry loop. + Assert.Empty(await service.ApplyForProcessStartAsync(process)); + if (reason == "cancel") + { + lifetime.Cancel(); + } + else if (reason == "exit") + { + service.MarkProcessExited(process.ProcessId); + } + else + { + await service.ApplyForDiscoveredProcessesAsync([]); + } + + await Assert.ThrowsAnyAsync(() => pending); + processes.Verify(x => x.RefreshProcessInfo(process), Times.Once); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), It.IsAny?>(), + It.IsAny()), Times.Never); + diagnostics.Verify( + x => x.LogProcessMonitoringEventAsync( + "PersistentRulePathRetry", process.Name, process.ProcessId, + It.Is(message => message.Contains("cancelled"))), Times.Once); + + // A later process with the same PID can be evaluated normally. + process.ExecutablePath = rule.ExecutablePath!; + Assert.Single(await service.ApplyForProcessStartAsync(process)); + } + + [Theory] + [InlineData(false, false, 4)] + [InlineData(true, false, 1)] + [InlineData(false, true, 2)] + public async Task PathRetry_IsBoundedAndUsesCurrentRules(bool removeRule, bool wrongPath, int expectedRefreshes) + { + var process = CreateProcess(); + process.ProcessId = Environment.ProcessId; + process.ExecutablePath = string.Empty; + var rule = CreateRule() with { ExecutablePath = @"C:\Games\Game.exe" }; + var rules = new List { rule }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processes = new Mock(MockBehavior.Strict); + int refreshes = 0; + processes.Setup(x => x.RefreshProcessInfo(process)).Returns(() => + { + if (++refreshes == 1 && removeRule) + { + rules.Clear(); + } + + if (refreshes > 1 && wrongPath) + { + process.ExecutablePath = @"C:\Other\Game.exe"; + } + + return Task.CompletedTask; + }); + var service = CreateService(rules, engine.Object, processService: processes.Object); + + Assert.Empty(await service.ApplyForProcessStartAsync(process)); + Assert.Equal(expectedRefreshes, refreshes); + await Task.Delay(300); + Assert.Equal(expectedRefreshes, refreshes); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), It.IsAny?>(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task PathRetry_WhenOriginalProcessExitsWithoutEvent_DoesNotApply() + { + using var child = Process.Start(new ProcessStartInfo("cmd.exe", "/d /c ping -n 60 127.0.0.1 >nul") + { + UseShellExecute = false, + CreateNoWindow = true, + })!; + try + { + var process = CreateProcess(); + process.ProcessId = child.Id; + process.ExecutablePath = string.Empty; + var rule = CreateRule() with { ExecutablePath = @"C:\Games\Game.exe" }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var processes = new Mock(MockBehavior.Strict); + processes.Setup(x => x.RefreshProcessInfo(process)).Returns(Task.CompletedTask); + var scheduled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var diagnostics = new Mock(); + diagnostics.Setup(x => x.LogProcessMonitoringEventAsync( + "PersistentRulePathRetry", process.Name, process.ProcessId, It.IsAny())) + .Callback(() => scheduled.TrySetResult()).Returns(Task.CompletedTask); + var service = CreateService([rule], engine.Object, processService: processes.Object, enhancedLogger: diagnostics.Object); + var pending = service.ApplyForProcessStartAsync(process); + await scheduled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(); + + Assert.Empty(await pending); + processes.Verify(x => x.RefreshProcessInfo(process), Times.Once); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), It.IsAny?>(), + It.IsAny()), Times.Never); + } + finally + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(); + } + } + } + private static PersistentRuleAutoApplyService CreateService( IReadOnlyList rules, IPersistentRulesEngine engine, @@ -485,7 +669,8 @@ private static PersistentRuleAutoApplyService CreateService( Func? nowProvider = null, IActivityAuditService? audit = null, IPersistentRulePriorityVerificationService? priorityVerifier = null, - IProcessService? processService = null) => + IProcessService? processService = null, + IEnhancedLoggingService? enhancedLogger = null) => new( new FakePersistentProcessRuleStore(rules), new PersistentProcessRuleMatcher(), @@ -496,7 +681,8 @@ private static PersistentRuleAutoApplyService CreateService( TimeSpan.FromSeconds(30), audit, priorityVerifier, - processService); + processService, + enhancedLogger); private static Mock CreateEngine( PersistentProcessRule rule, diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index fba67af..e91cf2d 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -14,6 +14,99 @@ namespace ThreadPilot.Core.Tests public sealed class ProcessMonitorManagerServiceTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Start_AppliesRulesToAlreadyRunningProcesses_AndRecoveryReevaluatesSnapshot(bool manual) + { + var process = new ProcessModel { ProcessId = 41, Name = "game" }; + var monitor = new FakeProcessMonitorService(); + monitor.RunningProcesses.Add(process); + var autoApply = CreateAutoApplyService(); + using var manager = CreateService( + monitor, + CreateAssociationService(new ProcessMonitorConfiguration()), + CreatePowerPlanService(), CreateNotificationService(), CreateProcessService(), + CreateCoreMaskService(), CreateAffinityApplyService(), autoApply); + + if (manual) + { + await manager.SetAutomationMonitoringEnabledAsync(true); + } + else + { + await manager.StartAsync(); + } + + autoApply.Verify( + x => x.ApplyForDiscoveredProcessesAsync( + It.Is>(ps => ps.Any(p => p.ProcessId == 41)), + It.IsAny()), Times.Once); + autoApply.Verify(x => x.MarkProcessExited(41), Times.Once); + var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + autoApply.Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.Is>(ps => ps.Any(p => p.ProcessId == 42)), + It.IsAny())) + .Callback(() => applied.TrySetResult()) + .ReturnsAsync(Array.Empty()); + monitor.RunningProcesses.Add(new ProcessModel { ProcessId = 42, Name = "missed-during-sleep" }); + monitor.RaiseSnapshotRefreshed(); + await applied.Task.WaitAsync(TimeSpan.FromSeconds(3)); + autoApply.Verify(x => x.MarkProcessExited(41), Times.Exactly(2)); + autoApply.Verify(x => x.MarkProcessExited(42), Times.Once); + } + + [Fact] + public async Task StartAsync_WhenFileLoggingCannotInitialize_StillStartsMonitoring() + { + var monitor = new FakeProcessMonitorService(); + var diagnostics = CreateEnhancedLogger(); + diagnostics.Setup(x => x.InitializeAsync()).ThrowsAsync(new IOException("Log directory unavailable")); + using var manager = CreateService( + monitor, + CreateAssociationService(new ProcessMonitorConfiguration()), + CreatePowerPlanService(), + CreateNotificationService(), + CreateProcessService(), + CreateCoreMaskService(), + CreateAffinityApplyService(), + enhancedLogger: diagnostics); + + await manager.StartAsync(); + + Assert.True(manager.IsRunning); + Assert.True(monitor.IsMonitoring); + } + + [Fact] + public async Task StartAsync_InitializesPersistentLoggingBeforeMonitoring() + { + var monitor = new FakeProcessMonitorService(); + var diagnostics = CreateEnhancedLogger(); + var initialized = false; + diagnostics.Setup(x => x.InitializeAsync()) + .Callback(() => initialized = true) + .Returns(Task.CompletedTask); + diagnostics.Setup(x => x.LogSystemEventAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => Assert.True(initialized)) + .Returns(Task.CompletedTask); + using var manager = CreateService( + monitor, + CreateAssociationService(new ProcessMonitorConfiguration()), + CreatePowerPlanService(), + CreateNotificationService(), + CreateProcessService(), + CreateCoreMaskService(), + CreateAffinityApplyService(), + enhancedLogger: diagnostics); + + await manager.StartAsync(); + + diagnostics.Verify(x => x.InitializeAsync(), Times.Once); + Assert.True(monitor.IsMonitoring); + } + [Fact] public async Task StartAsync_LoadsConfiguration_AndStartsMonitoring() { @@ -843,6 +936,42 @@ public async Task Dispose_CompletesOnBlockingSynchronizationContext() Assert.Equal(1, processMonitor.DisposeCalls); } + [Fact] + public async Task StopAsync_CancelsUnassociatedAutomationAndRestartGetsFreshLifetime() + { + var monitor = new FakeProcessMonitorService(); + var automation = CreateAutoApplyService(); + var tokens = new List(); + automation.Setup(x => x.ApplyForProcessStartAsync( + It.IsAny(), It.IsAny())) + .Callback((_, token) => tokens.Add(token)) + .ReturnsAsync(Array.Empty()); + using var manager = CreateService( + monitor, + CreateAssociationService(new ProcessMonitorConfiguration()), + CreatePowerPlanService(), + CreateNotificationService(), + CreateProcessService(), + CreateCoreMaskService(), + CreateAffinityApplyService(), + automation); + + await manager.StartAsync(); + monitor.RaiseProcessStarted(new ProcessModel { ProcessId = 42, Name = "game" }); + Assert.Single(tokens); + Assert.True(tokens[0].CanBeCanceled); + Assert.False(tokens[0].IsCancellationRequested); + + await manager.StopAsync(); + Assert.True(tokens[0].IsCancellationRequested); + await manager.StartAsync(); + monitor.RaiseProcessStarted(new ProcessModel { ProcessId = 43, Name = "game" }); + Assert.Equal(2, tokens.Count); + Assert.False(tokens[1].IsCancellationRequested); + await manager.StopAsync(); + Assert.True(tokens[1].IsCancellationRequested); + } + private static ProcessMonitorManagerService CreateService( FakeProcessMonitorService processMonitorService, Mock associationService, @@ -975,11 +1104,11 @@ private sealed class FakeProcessMonitorService : IProcessMonitorService public event EventHandler? ProcessStopped; - public event EventHandler? MonitoringStatusChanged - { - add { } - remove { } - } + public event EventHandler? MonitoringStatusChanged; + + public void RaiseSnapshotRefreshed() => this.MonitoringStatusChanged?.Invoke( + this, + new MonitoringStatusEventArgs(true, true, false, snapshotRefreshed: true)); public List RunningProcesses { get; } = new(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorRecoveryTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorRecoveryTests.cs new file mode 100644 index 0000000..1007e9e --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorRecoveryTests.cs @@ -0,0 +1,220 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessMonitorRecoveryTests + { + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task LostMonitoring_RecreatesWatchersAndRefreshesExistingProcesses(bool resume) + { + var wake = new FakeWakeEvents(); + var created = new List(); + var processes = new ObservableCollection(); + var source = new Mock(); + source.Setup(x => x.GetProcessesAsync()).ReturnsAsync(() => new ObservableCollection(processes)); + var settings = new Mock(); + settings.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel + { + EnableWmiMonitoring = true, + EnableFallbackPolling = false, + }); + var recreated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var monitor = new ProcessMonitorService(source.Object, settings.Object, + NullLogger.Instance, () => + { + var watchers = new FakeWatchers(); + created.Add(watchers); + return watchers; + }, wake); + monitor.MonitoringStatusChanged += async (_, _) => + { + if (created.Count >= 2 && (await monitor.GetRunningProcessesAsync()).Any(x => x.ProcessId == 123)) + { + recreated.TrySetResult(); + } + }; + + await monitor.StartMonitoringAsync(); + processes.Add(new ProcessModel { ProcessId = 123, Name = "already-running" }); + if (resume) + { + wake.Raise(); + } + else + { + created[0].Fail("subscription cancelled"); + } + + await recreated.Task.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.True(created[0].Disposed); + Assert.True(monitor.IsWmiAvailable); + Assert.Contains(await monitor.GetRunningProcessesAsync(), p => p.ProcessId == 123); + } + + [Fact] + public async Task Start_SnapshotsAfterSubscribingSoProcessesStartedDuringWatcherSetupAreIncluded() + { + using var fixture = new MonitorFixture(); + fixture.Source.Setup(x => x.GetProcessesAsync()).ReturnsAsync(() => + fixture.Created.Count == 0 ? new ObservableCollection() : + new ObservableCollection { new() { ProcessId = 123, Name = "started-during-subscription" } }); + + await fixture.Monitor.StartMonitoringAsync(); + + Assert.Contains(await fixture.Monitor.GetRunningProcessesAsync(), p => p.ProcessId == 123); + } + + [Fact] + public async Task Watchdog_RecreatesSilentWatchersAndIgnoresRetiredCallbacks() + { + using var fixture = new MonitorFixture(); + await fixture.Monitor.StartMonitoringAsync(); + fixture.Processes.Add(new ProcessModel { ProcessId = 123, Name = "missed" }); + + await fixture.Monitor.CheckHealthAsync(DateTime.UtcNow.AddMinutes(3)); + fixture.Created[0].Started(999, "stale.exe"); + fixture.Created[0].Fail("late Stopped callback"); + + Assert.Equal(2, fixture.Created.Count); + Assert.True(fixture.Created[0].Disposed); + Assert.Contains(await fixture.Monitor.GetRunningProcessesAsync(), p => p.ProcessId == 123); + Assert.DoesNotContain(await fixture.Monitor.GetRunningProcessesAsync(), p => p.ProcessId == 999); + Assert.True(fixture.Monitor.IsWmiAvailable); + } + + [Fact] + public async Task WmiUnavailable_PollingDetectsNewProcessesAndWatchdogRetriesWithBackoff() + { + using var fixture = new MonitorFixture(failWmi: true, fallback: true); + var detected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Monitor.ProcessStarted += (_, e) => + { + if (e.Process.ProcessId == 123) + { + detected.TrySetResult(); + } + }; + await fixture.Monitor.StartMonitoringAsync(); + Assert.True(fixture.Monitor.IsFallbackPollingActive); + Assert.False(fixture.Monitor.IsWmiAvailable); + fixture.Processes.Add(new ProcessModel { ProcessId = 123, Name = "polling" }); + await detected.Task.WaitAsync(TimeSpan.FromSeconds(4)); + + var now = DateTime.UtcNow; + await fixture.Monitor.CheckHealthAsync(now); + Assert.Single(fixture.Created); + await fixture.Monitor.CheckHealthAsync(now.AddSeconds(11)); + Assert.Equal(2, fixture.Created.Count); + await fixture.Monitor.CheckHealthAsync(now.AddSeconds(12)); + Assert.Equal(2, fixture.Created.Count); + + fixture.FailWmi = false; + await fixture.Monitor.CheckHealthAsync(now.AddMinutes(1)); + Assert.True(fixture.Monitor.IsWmiAvailable); + Assert.False(fixture.Monitor.IsFallbackPollingActive); + } + + [Fact] + public async Task StopDuringRecovery_LeavesMonitoringStoppedAndWakeUnsubscribed() + { + using var fixture = new MonitorFixture(); + await fixture.Monitor.StartMonitoringAsync(); + var enumerating = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + fixture.Source.Setup(x => x.GetProcessesAsync()).Returns(() => + { + enumerating.TrySetResult(); + return release.Task; + }); + var recovery = fixture.Monitor.CheckHealthAsync(DateTime.UtcNow.AddMinutes(3)); + await enumerating.Task.WaitAsync(TimeSpan.FromSeconds(3)); + var stop = fixture.Monitor.StopMonitoringAsync(); + release.SetResult(new ObservableCollection()); + await Task.WhenAll(recovery, stop).WaitAsync(TimeSpan.FromSeconds(3)); + fixture.Wake.Raise(); + await fixture.Monitor.CheckHealthAsync(DateTime.UtcNow.AddHours(1)); + + Assert.False(fixture.Monitor.IsMonitoring); + Assert.False(fixture.Monitor.IsWmiAvailable); + Assert.All(fixture.Created, w => Assert.True(w.Disposed)); + Assert.Equal(2, fixture.Created.Count); + } + + private sealed class MonitorFixture : IDisposable + { + public MonitorFixture(bool failWmi = false, bool fallback = false) + { + this.FailWmi = failWmi; + this.Source.Setup(x => x.GetProcessesAsync()).ReturnsAsync(() => new ObservableCollection(this.Processes)); + var settings = new Mock(); + settings.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel + { + EnableWmiMonitoring = true, + EnableFallbackPolling = fallback, + FallbackPollingIntervalMs = 1000, + }); + this.Monitor = new ProcessMonitorService(this.Source.Object, settings.Object, + NullLogger.Instance, () => + { + var watchers = new FakeWatchers { ThrowOnStart = this.FailWmi }; + this.Created.Add(watchers); + return watchers; + }, this.Wake); + } + + public Mock Source { get; } = new(); + public ObservableCollection Processes { get; } = new(); + + public FakeWakeEvents Wake { get; } = new(); + + public List Created { get; } = new(); + + public ProcessMonitorService Monitor { get; } + + public bool FailWmi { get; set; } + + public void Dispose() => this.Monitor.Dispose(); + } + + internal sealed class FakeWatchers : IProcessWatchers + { + public Action Fail { get; private set; } = _ => { }; + + public Action Started { get; private set; } = (_, _) => { }; + + public bool Disposed { get; private set; } + + public bool ThrowOnStart { get; init; } + + public void Start(Action started, Action stopped, Action failed) + { + this.Started = started; + this.Fail = failed; + if (this.ThrowOnStart) + { + throw new InvalidOperationException("WMI unavailable"); + } + } + + public void Dispose() => this.Disposed = true; + } + + internal sealed class FakeWakeEvents : IMonitoringWakeEvents + { + public event EventHandler? Wake; + public void Start() + { + } + public void Raise() => this.Wake?.Invoke(this, EventArgs.Empty); + public void Dispose() + { + } + } + } +} diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 96364a2..9fdf2e8 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -23,10 +23,10 @@ copies text, and an elevated process should not deserialize data another program can put there. --> false CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.7.8 - 1.7.8.0 - 1.7.8.0 - 1.7.8 + 1.7.9 + 1.7.9.0 + 1.7.9.0 + 1.7.9 diff --git a/app.manifest b/app.manifest index b415938..31961ab 100644 --- a/app.manifest +++ b/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index 6230cb5..21e8345 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8", + [string]$Version = "1.7.9", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 272268c..eda147b 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8", + [string]$Version = "1.7.9", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index a7d698b..6d17405 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.7.8" + [string]$Version = "1.7.9" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index ee64e28..0e94a9d 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.7.8 + 1.7.9 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.7.8 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.7.9 threadpilot process powerplan performance windows diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 44a69db..4444fa9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## v1.7.9 - Saved rules recover after sleep + +- Recreate process monitoring after sleep/hibernation resume and session unlock, then rescan running processes and apply saved rules. +- Recover stopped or silent WMI subscriptions automatically, with polling fallback and independent WMI retries when enabled. +- Use the same monitoring startup path for app startup and manual Start; record monitoring recovery in diagnostics. +- Retry path-based rule matching when a newly started process's executable path is temporarily unavailable. + ## v1.7.8 - Game Boost, security hardening and a much lighter app - Stable release with everything from v1.7.8-beta.1 and v1.7.8-beta.2. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a9760f1..13318a7 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -24,6 +24,11 @@ What to do: - Reboot if repository consistency is suspected to be corrupted. - Keep fallback polling enabled (default behavior). +## Saved Rules and Unavailable Executable Paths +A path-based saved rule can wait for executable metadata when a process first appears. ThreadPilot retries metadata lookup up to three times, with delays totaling three seconds, independently of WMI or fallback polling. It stops when the path is available, the process exits, monitoring stops, or the relevant rule is removed or disabled. The refreshed path must still match the current rule. + +Look for `PersistentRulePathRetry` events in `%LOCALAPPDATA%\ThreadPilot\Logs` for scheduling, recovery, cancellation, exhausted retries, and matching/application outcomes. If the path stays inaccessible, the rule remains unapplied; retries do not run indefinitely. + ## Performance Counter Access Denied Some performance counters may fail on restricted systems. diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index d4cc9b1..589df8a 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,3 +1,9 @@ +## ThreadPilot v1.7.9 + +Saved rules recover after sleep and hibernation: process monitoring renews WMI subscriptions, rescans running processes, and evaluates rules after resume/unlock. Includes a watchdog, independent WMI retries, polling fallback and retries for temporarily unavailable executable paths. + +[Full release notes](../releases/v1.7.9.md). Artifacts unsigned; verify SHA256 checksums. + ## ThreadPilot v1.7.8 Stable release with everything from beta.1 and beta.2: Game Boost (off by default), a full security and performance audit (window-open CPU ~25–30% → ~2.3% of a core, near-zero in the tray), an installer that always installs in Program Files, and portable copies that can install themselves for autostart. Game Boost is not yet validated on real dual-CCD Ryzen and Intel hybrid CPUs; testers wanted. diff --git a/docs/releases/v1.7.9.md b/docs/releases/v1.7.9.md new file mode 100644 index 0000000..2e3460d --- /dev/null +++ b/docs/releases/v1.7.9.md @@ -0,0 +1,11 @@ +# ThreadPilot v1.7.9 + +This release fixes saved-rule monitoring that could stop responding after Windows sleep or hibernation ([#95](https://github.com/PrimeBuild-pc/ThreadPilot/issues/95)). + +- Monitoring recreates its WMI subscriptions after resume and session unlock, rescans processes already running, and evaluates saved rules again. +- A lightweight watchdog recovers stopped or silent subscriptions. When WMI is unavailable, polling keeps detecting processes if fallback polling is enabled; WMI retries run independently of fallback polling. +- Automatic startup and manual Start use the same startup path, including the initial process scan and rule evaluation. +- Path-based rules retry when a process starts before Windows exposes its executable path. +- File diagnostics initialize before automation starts and record recovery attempts and results. + +Settings and saved rules are preserved. Artifacts remain unsigned while code-signing enrollment is pending; SHA-256 checksums are included with the release.