diff --git a/MainWindow.Behaviors.partial.cs b/MainWindow.Behaviors.partial.cs index 0e205f8..bcf8d1d 100644 --- a/MainWindow.Behaviors.partial.cs +++ b/MainWindow.Behaviors.partial.cs @@ -782,23 +782,23 @@ private async Task OnMonitoringToggleRequestedAsync(MonitoringToggleEventArgs e) { await this.processMonitorManagerService.StartAsync(); await this.notificationService.ShowSuccessNotificationAsync( - "Monitoring Enabled", - "Process monitoring and power plan management has been enabled"); + "Automation Monitoring Enabled", + "Process rule automation and power plan management have been enabled."); } else { await this.processMonitorManagerService.StopAsync(); await this.notificationService.ShowNotificationAsync( - "Monitoring Disabled", - "Process monitoring and power plan management has been disabled", + "Automation Monitoring Disabled", + "Process rule automation and power plan management have been disabled.", Models.NotificationType.Warning); } } catch (Exception ex) { await this.notificationService.ShowErrorNotificationAsync( - "Monitoring Error", - "Failed to toggle process monitoring", + "Automation Monitoring Error", + "Failed to toggle automation monitoring.", ex); } } @@ -969,10 +969,7 @@ private async Task HandleShortcutActionAsync(string actionName) { this.ShowInTaskbar = false; this.Hide(); - this.SuspendHiddenModeRefreshes(); - this.processViewModel.PauseRefresh(); - this.processViewModel.SetProcessViewActive(false); - _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync(); + this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); } else { @@ -1273,8 +1270,8 @@ private void OnMonitoringStatusChanged(object? sender, MonitoringStatusEventArgs if (e.Error != null && this.settingsService.Settings.EnableErrorNotifications) { this.notificationService.ShowErrorNotificationAsync( - "Monitoring Error", - e.StatusMessage ?? "An error occurred with process monitoring", + "Automation Monitoring Error", + e.StatusMessage ?? "An error occurred with automation monitoring.", e.Error); } } @@ -1288,8 +1285,8 @@ private void OnProcessMonitorManagerStatusChanged(object? sender, ServiceStatusE if (!e.IsRunning && e.Error != null && this.settingsService.Settings.EnableErrorNotifications) { this.notificationService.ShowErrorNotificationAsync( - "Process Monitoring Error", - e.Details ?? "Process monitoring manager encountered an error", + "Automation Monitoring Error", + e.Details ?? "Automation monitoring encountered an error.", e.Error); } } @@ -1300,42 +1297,22 @@ protected override void OnStateChanged(EventArgs e) { if (this.WindowState == WindowState.Minimized) { + var activityState = AppActivityState.Minimized; if (this.settingsService.Settings.MinimizeToTray) { this.ShowInTaskbar = false; this.Hide(); this.systemTrayService.Show(); + activityState = AppActivityState.TrayHidden; } - this.SuspendHiddenModeRefreshes(); - - if (this.processViewModel != null) - { - this.processViewModel.PauseRefresh(); - this.processViewModel.SetProcessViewActive(false); - } - - if (this.performanceViewModel != null) - { - _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync(); - } + this.ApplyAppRefreshPolicy(activityState); } else if (this.WindowState == WindowState.Normal || this.WindowState == WindowState.Maximized) { this.ShowInTaskbar = true; - this.ResumeForegroundRefreshes(); - - if (this.processViewModel != null) - { - this.processViewModel.SetProcessViewActive(this.ProcessManagementTab.Visibility == Visibility.Visible); - this.processViewModel.ResumeRefresh(); - } - - if (this.performanceViewModel != null) - { - _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync(); - } + this.ApplyAppRefreshPolicy(this.GetForegroundActivityState()); } } catch (Exception ex) @@ -1364,7 +1341,6 @@ private void ResumeForegroundRefreshes() this.systemTrayUpdateTimer.Interval = SystemTrayUpdateBaseIntervalMs; } this.systemTrayUpdateTimer?.Start(); - this.powerPlanViewModel.ResumeAutoRefresh(refreshImmediately: true); _ = this.Dispatcher.InvokeAsync(async () => { @@ -1380,6 +1356,52 @@ private void ResumeForegroundRefreshes() }); } + private AppActivityState GetForegroundActivityState() + { + return this.ProcessManagementTab.Visibility == Visibility.Visible + ? AppActivityState.ForegroundProcessView + : AppActivityState.ForegroundOtherTab; + } + + private void ApplyAppRefreshPolicy(AppActivityState state) + { + var decision = AppRefreshPolicy.Evaluate(state); + var isHiddenState = state is AppActivityState.Minimized or AppActivityState.TrayHidden; + var isProcessViewActive = state == AppActivityState.ForegroundProcessView; + + if (isHiddenState) + { + this.isSystemTrayUpdatesSuspended = true; + this.systemTrayUpdateTimer?.Stop(); + Interlocked.Exchange(ref this.isSystemTrayUpdateInProgress, 0); + } + else + { + this.ResumeForegroundRefreshes(); + } + + this.processViewModel.SetProcessViewActive(isProcessViewActive); + this.processViewModel.ApplyRefreshDecision(decision); + + if (decision.PowerPlanUiRefreshEnabled) + { + this.powerPlanViewModel.ResumeAutoRefresh(refreshImmediately: state != AppActivityState.ForegroundOtherTab); + } + else + { + this.powerPlanViewModel.PauseAutoRefresh(); + } + + if (decision.PerformanceUiMonitoringEnabled) + { + _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync(); + } + else + { + _ = this.performanceViewModel.SuspendBackgroundMonitoringAsync(); + } + } + protected override void OnSourceInitialized(EventArgs e) { base.OnSourceInitialized(e); @@ -1447,15 +1469,16 @@ private void ShowWindowFromTray(string? tabTag = null) this.Activate(); this.Focus(); this.Topmost = false; + this.Activate(); + this.Focus(); var processViewWillBeActive = tabTag == null ? this.ProcessManagementTab.Visibility == Visibility.Visible : string.Equals(tabTag, "Process", StringComparison.Ordinal); - this.ResumeForegroundRefreshes(); - this.processViewModel.SetProcessViewActive(processViewWillBeActive); - this.processViewModel.ResumeRefresh(); - _ = this.performanceViewModel.ResumeBackgroundMonitoringAsync(); + this.ApplyAppRefreshPolicy(processViewWillBeActive + ? AppActivityState.ForegroundProcessView + : AppActivityState.ForegroundOtherTab); if (tabTag != null) { @@ -1664,10 +1687,21 @@ private void ApplySectionVisibility(string tag) this.NavTweaks.IsActive = tag == "Tweaks"; this.NavSettings.IsActive = tag == "Settings"; - this.processViewModel.SetProcessViewActive( - string.Equals(tag, "Process", StringComparison.Ordinal) && - this.IsVisible && - this.WindowState != WindowState.Minimized); + if (!this.IsVisible) + { + this.ApplyAppRefreshPolicy(AppActivityState.TrayHidden); + return; + } + + if (this.WindowState == WindowState.Minimized) + { + this.ApplyAppRefreshPolicy(AppActivityState.Minimized); + return; + } + + this.ApplyAppRefreshPolicy(string.Equals(tag, "Process", StringComparison.Ordinal) + ? AppActivityState.ForegroundProcessView + : AppActivityState.ForegroundOtherTab); } private void NavMenuItem_Click(object sender, RoutedEventArgs e) diff --git a/MainWindow.xaml b/MainWindow.xaml index dfea231..edaa0af 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -323,7 +323,7 @@ Margin="0,0,0,6"/> . + */ +namespace ThreadPilot.Services +{ + using Microsoft.Extensions.Logging; + using ThreadPilot.Models; + + public enum AffinityApplyFailureReason + { + None, + InvalidMask, + ProcessTerminated, + AccessDenied, + VerificationMismatch, + ApplyFailed, + } + + public sealed record AffinityApplyResult( + bool Success, + long RequestedMask, + long VerifiedMask, + AffinityApplyFailureReason FailureReason, + string Message) + { + public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMask) => + new(true, requestedMask, verifiedMask, AffinityApplyFailureReason.None, "Affinity applied successfully."); + + public static AffinityApplyResult Failed( + long requestedMask, + long verifiedMask, + AffinityApplyFailureReason failureReason, + string message) => + new(false, requestedMask, verifiedMask, failureReason, message); + } + + public interface IAffinityApplyService + { + Task ApplyAsync(ProcessModel process, long requestedMask); + } + + public sealed class AffinityApplyService : IAffinityApplyService + { + private readonly IProcessService processService; + private readonly ICpuTopologyService cpuTopologyService; + private readonly ILogger logger; + + public AffinityApplyService( + IProcessService processService, + ICpuTopologyService cpuTopologyService, + ILogger logger) + { + this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); + this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ApplyAsync(ProcessModel process, long requestedMask) + { + ArgumentNullException.ThrowIfNull(process); + + var startingMask = process.ProcessorAffinity; + + if (requestedMask == 0) + { + return AffinityApplyResult.Failed( + requestedMask, + startingMask, + AffinityApplyFailureReason.InvalidMask, + "Affinity mask cannot be zero."); + } + + if (!this.cpuTopologyService.IsAffinityMaskValid(requestedMask)) + { + return AffinityApplyResult.Failed( + requestedMask, + startingMask, + AffinityApplyFailureReason.InvalidMask, + "Affinity mask is not valid for this CPU topology."); + } + + if (!await this.IsProcessRunningAsync(process).ConfigureAwait(false)) + { + return AffinityApplyResult.Failed( + requestedMask, + startingMask, + AffinityApplyFailureReason.ProcessTerminated, + "Process is no longer running."); + } + + try + { + await this.processService.SetProcessorAffinity(process, requestedMask).ConfigureAwait(false); + } + catch (Exception ex) when (IsAccessDenied(ex)) + { + this.logger.LogWarning( + ex, + "Affinity apply blocked for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false); + return AffinityApplyResult.Failed( + requestedMask, + process.ProcessorAffinity, + AffinityApplyFailureReason.AccessDenied, + "Affinity change blocked (anti-cheat or insufficient privileges)."); + } + catch (Exception ex) when (IsProcessTerminated(ex)) + { + this.logger.LogDebug( + ex, + "Process terminated while applying affinity to {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + return AffinityApplyResult.Failed( + requestedMask, + process.ProcessorAffinity, + AffinityApplyFailureReason.ProcessTerminated, + "Process exited before affinity could be applied."); + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "Affinity apply failed for process {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + + await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false); + return AffinityApplyResult.Failed( + requestedMask, + process.ProcessorAffinity, + AffinityApplyFailureReason.ApplyFailed, + $"Failed to set processor affinity: {ex.Message}"); + } + + if (!await this.TryRefreshProcessInfoAsync(process).ConfigureAwait(false)) + { + return AffinityApplyResult.Failed( + requestedMask, + process.ProcessorAffinity, + AffinityApplyFailureReason.ProcessTerminated, + "Process exited before affinity could be verified."); + } + + var verifiedMask = process.ProcessorAffinity; + if (verifiedMask != requestedMask) + { + return AffinityApplyResult.Failed( + requestedMask, + verifiedMask, + AffinityApplyFailureReason.VerificationMismatch, + $"Windows reported affinity 0x{verifiedMask:X} after requesting 0x{requestedMask:X}."); + } + + return AffinityApplyResult.Succeeded(requestedMask, verifiedMask); + } + + private static bool IsAccessDenied(Exception ex) + { + var message = ex.Message ?? string.Empty; + return ex is UnauthorizedAccessException || + message.Contains("access denied", StringComparison.OrdinalIgnoreCase) || + message.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase) || + message.Contains("anti cheat", StringComparison.OrdinalIgnoreCase) || + message.Contains("protected", StringComparison.OrdinalIgnoreCase) || + message.Contains("insufficient privileges", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsProcessTerminated(Exception ex) + { + var message = ex.Message ?? string.Empty; + return ex is ArgumentException || + (ex is InvalidOperationException && + (message.Contains("process", StringComparison.OrdinalIgnoreCase) && + (message.Contains("exit", StringComparison.OrdinalIgnoreCase) || + message.Contains("terminated", StringComparison.OrdinalIgnoreCase) || + message.Contains("not running", StringComparison.OrdinalIgnoreCase)))); + } + + private async Task IsProcessRunningAsync(ProcessModel process) + { + try + { + return await this.processService.IsProcessStillRunning(process).ConfigureAwait(false); + } + catch (Exception ex) when (IsAccessDenied(ex)) + { + this.logger.LogDebug( + ex, + "Could not confirm process state before affinity apply for {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return true; + } + catch (Exception ex) + { + this.logger.LogDebug( + ex, + "Process state check failed before affinity apply for {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return false; + } + } + + private async Task TryRefreshProcessInfoAsync(ProcessModel process) + { + try + { + await this.processService.RefreshProcessInfo(process).ConfigureAwait(false); + return true; + } + catch (Exception ex) when (IsAccessDenied(ex)) + { + this.logger.LogDebug( + ex, + "Could not refresh process after affinity apply for {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return true; + } + catch (Exception ex) + { + this.logger.LogDebug( + ex, + "Process refresh failed after affinity apply for {ProcessName} (PID: {ProcessId})", + process.Name, + process.ProcessId); + return false; + } + } + } +} diff --git a/Services/AppRefreshPolicy.cs b/Services/AppRefreshPolicy.cs new file mode 100644 index 0000000..80a6cbf --- /dev/null +++ b/Services/AppRefreshPolicy.cs @@ -0,0 +1,81 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + /// + /// Represents the current UI activity state used to decide refresh work. + /// + public enum AppActivityState + { + ForegroundProcessView, + ForegroundOtherTab, + Minimized, + TrayHidden, + } + + /// + /// Describes refresh and monitoring work allowed for a UI activity state. + /// + public sealed record AppRefreshDecision( + bool ProcessUiRefreshEnabled, + bool ImmediateProcessRefresh, + bool VirtualizedPreloadEnabled, + bool PerformanceUiMonitoringEnabled, + bool PowerPlanUiRefreshEnabled, + bool BackgroundAutomationEnabled); + + /// + /// Central policy for foreground/background refresh decisions. + /// + public static class AppRefreshPolicy + { + public static AppRefreshDecision Evaluate(AppActivityState state) + { + return state switch + { + AppActivityState.ForegroundProcessView => new AppRefreshDecision( + ProcessUiRefreshEnabled: true, + ImmediateProcessRefresh: true, + VirtualizedPreloadEnabled: true, + PerformanceUiMonitoringEnabled: true, + PowerPlanUiRefreshEnabled: true, + BackgroundAutomationEnabled: true), + AppActivityState.ForegroundOtherTab => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: true, + PowerPlanUiRefreshEnabled: true, + BackgroundAutomationEnabled: true), + AppActivityState.Minimized or AppActivityState.TrayHidden => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: false, + PowerPlanUiRefreshEnabled: false, + BackgroundAutomationEnabled: true), + _ => new AppRefreshDecision( + ProcessUiRefreshEnabled: false, + ImmediateProcessRefresh: false, + VirtualizedPreloadEnabled: false, + PerformanceUiMonitoringEnabled: false, + PowerPlanUiRefreshEnabled: false, + BackgroundAutomationEnabled: true), + }; + } + } +} diff --git a/Services/ForegroundProcessService.cs b/Services/ForegroundProcessService.cs new file mode 100644 index 0000000..d3c6541 --- /dev/null +++ b/Services/ForegroundProcessService.cs @@ -0,0 +1,74 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using Microsoft.Extensions.Logging; + + public readonly record struct ForegroundWindowSnapshot( + IntPtr WindowHandle, + int ProcessId, + bool IsVisible, + bool IsCloaked); + + public interface IForegroundWindowProvider + { + bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot); + } + + public interface IForegroundProcessService + { + int? TryGetForegroundProcessId(); + } + + public sealed class ForegroundProcessService : IForegroundProcessService + { + private readonly IForegroundWindowProvider foregroundWindowProvider; + private readonly ILogger logger; + + public ForegroundProcessService( + IForegroundWindowProvider foregroundWindowProvider, + ILogger logger) + { + this.foregroundWindowProvider = foregroundWindowProvider ?? throw new ArgumentNullException(nameof(foregroundWindowProvider)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public int? TryGetForegroundProcessId() + { + try + { + if (!this.foregroundWindowProvider.TryGetForegroundWindow(out var snapshot)) + { + return null; + } + + if (snapshot.ProcessId <= 0 || !snapshot.IsVisible || snapshot.IsCloaked) + { + return null; + } + + return snapshot.ProcessId; + } + catch (Exception ex) + { + this.logger.LogDebug(ex, "Foreground process detection failed"); + return null; + } + } + } +} diff --git a/Services/PassiveProcessErrorThrottle.cs b/Services/PassiveProcessErrorThrottle.cs new file mode 100644 index 0000000..75ab89b --- /dev/null +++ b/Services/PassiveProcessErrorThrottle.cs @@ -0,0 +1,70 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Concurrent; + + public enum PassiveProcessErrorKind + { + AccessDenied, + Terminated, + Unknown, + } + + public interface IPassiveProcessErrorThrottle + { + bool ShouldLog(int processId, PassiveProcessErrorKind errorKind); + } + + public sealed class PassiveProcessErrorThrottle : IPassiveProcessErrorThrottle + { + private readonly ConcurrentDictionary<(int ProcessId, PassiveProcessErrorKind ErrorKind), DateTimeOffset> lastLogByError = new(); + private readonly Func nowProvider; + private readonly TimeSpan ttl; + + public PassiveProcessErrorThrottle() + : this(TimeSpan.FromMinutes(5), () => DateTimeOffset.UtcNow) + { + } + + public PassiveProcessErrorThrottle(TimeSpan ttl, Func nowProvider) + { + if (ttl <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(ttl), "TTL must be greater than zero."); + } + + this.ttl = ttl; + this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); + } + + public bool ShouldLog(int processId, PassiveProcessErrorKind errorKind) + { + var now = this.nowProvider(); + var key = (processId, errorKind); + + if (this.lastLogByError.TryGetValue(key, out var lastLog) && now - lastLog < this.ttl) + { + return false; + } + + this.lastLogByError[key] = now; + return true; + } + } +} diff --git a/Services/PowerPlanTransitionGate.cs b/Services/PowerPlanTransitionGate.cs new file mode 100644 index 0000000..41f8b08 --- /dev/null +++ b/Services/PowerPlanTransitionGate.cs @@ -0,0 +1,78 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + public enum PowerPlanTransitionSuppressionReason + { + None, + AlreadyActive, + RecentDuplicateRequest, + } + + public sealed record PowerPlanTransitionDecision( + bool ShouldApply, + PowerPlanTransitionSuppressionReason SuppressionReason); + + public sealed class PowerPlanTransitionGate + { + private readonly TimeSpan duplicateWindow; + private readonly Func nowProvider; + private readonly object lockObject = new(); + private string? lastRequestedPowerPlanGuid; + private DateTimeOffset lastRequestTime; + + public PowerPlanTransitionGate() + : this(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow) + { + } + + public PowerPlanTransitionGate(TimeSpan duplicateWindow, Func nowProvider) + { + this.duplicateWindow = duplicateWindow < TimeSpan.Zero ? TimeSpan.Zero : duplicateWindow; + this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); + } + + public PowerPlanTransitionDecision ShouldApply(string targetPowerPlanGuid, string? currentPowerPlanGuid) + { + if (string.Equals(targetPowerPlanGuid, currentPowerPlanGuid, StringComparison.OrdinalIgnoreCase)) + { + return new PowerPlanTransitionDecision(false, PowerPlanTransitionSuppressionReason.AlreadyActive); + } + + lock (this.lockObject) + { + var now = this.nowProvider(); + if (string.Equals(this.lastRequestedPowerPlanGuid, targetPowerPlanGuid, StringComparison.OrdinalIgnoreCase) && + now - this.lastRequestTime < this.duplicateWindow) + { + return new PowerPlanTransitionDecision(false, PowerPlanTransitionSuppressionReason.RecentDuplicateRequest); + } + } + + return new PowerPlanTransitionDecision(true, PowerPlanTransitionSuppressionReason.None); + } + + public void RecordAttempt(string targetPowerPlanGuid) + { + lock (this.lockObject) + { + this.lastRequestedPowerPlanGuid = targetPowerPlanGuid; + this.lastRequestTime = this.nowProvider(); + } + } + } +} diff --git a/Services/ProcessClassifier.cs b/Services/ProcessClassifier.cs new file mode 100644 index 0000000..2d737af --- /dev/null +++ b/Services/ProcessClassifier.cs @@ -0,0 +1,78 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using ThreadPilot.Models; + + public readonly record struct ProcessClassificationContext( + int? ForegroundProcessId, + bool AccessDenied = false, + bool Terminated = false); + + public interface IProcessClassifier + { + ProcessClassification Classify(ProcessModel process, ProcessClassificationContext context); + } + + public sealed class ProcessClassifier : IProcessClassifier + { + private readonly ProcessFilterService processFilterService; + + public ProcessClassifier(ProcessFilterService processFilterService) + { + this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); + } + + public ProcessClassification Classify(ProcessModel process, ProcessClassificationContext context) + { + ArgumentNullException.ThrowIfNull(process); + + if (context.Terminated) + { + return ProcessClassification.Terminated; + } + + if (context.AccessDenied) + { + return ProcessClassification.ProtectedOrAccessDenied; + } + + if (context.ForegroundProcessId == process.ProcessId) + { + return ProcessClassification.ForegroundApp; + } + + if (this.processFilterService.IsSystemProcess(process)) + { + return ProcessClassification.System; + } + + if (process.HasVisibleWindow) + { + return ProcessClassification.VisibleWindowApp; + } + + if (!string.IsNullOrWhiteSpace(process.Name)) + { + return ProcessClassification.BackgroundUser; + } + + return ProcessClassification.Unknown; + } + } +} diff --git a/Services/ProcessFilterService.cs b/Services/ProcessFilterService.cs index d5f05ae..3c4871e 100644 --- a/Services/ProcessFilterService.cs +++ b/Services/ProcessFilterService.cs @@ -66,7 +66,7 @@ public IReadOnlyList FilterAndSort(IEnumerable sourc if (criteria.HideSystemProcesses) { - filtered = filtered.Where(p => !IsSystemProcess(p)); + filtered = filtered.Where(p => !this.IsSystemProcess(p)); } if (criteria.HideIdleProcesses) @@ -86,7 +86,7 @@ public IReadOnlyList FilterAndSort(IEnumerable sourc return sorted.ToList(); } - private static bool IsSystemProcess(ProcessModel process) + public bool IsSystemProcess(ProcessModel process) { if (process == null) { diff --git a/Services/ProcessListDeltaUpdater.cs b/Services/ProcessListDeltaUpdater.cs new file mode 100644 index 0000000..0ced855 --- /dev/null +++ b/Services/ProcessListDeltaUpdater.cs @@ -0,0 +1,103 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Linq; + using ThreadPilot.Models; + + public sealed record ProcessListDeltaResult(ProcessModel? SelectedProcess, bool SelectedProcessTerminated); + + /// + /// Applies process snapshots to the UI collection while preserving existing models by PID. + /// + public static class ProcessListDeltaUpdater + { + public static ProcessListDeltaResult ApplyDelta( + ObservableCollection target, + IEnumerable snapshot, + int? selectedProcessId) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(snapshot); + + var currentByPid = target + .GroupBy(process => process.ProcessId) + .ToDictionary(group => group.Key, group => group.First()); + var snapshotByPid = new Dictionary(); + foreach (var process in snapshot) + { + snapshotByPid[process.ProcessId] = process; + } + + var seenPids = new HashSet(); + ProcessModel? selectedProcess = null; + + foreach (var incoming in snapshotByPid.Values) + { + seenPids.Add(incoming.ProcessId); + + if (currentByPid.TryGetValue(incoming.ProcessId, out var existing)) + { + CopyProcessState(incoming, existing); + if (selectedProcessId == incoming.ProcessId) + { + selectedProcess = existing; + } + + continue; + } + + target.Add(incoming); + if (selectedProcessId == incoming.ProcessId) + { + selectedProcess = incoming; + } + } + + for (int i = target.Count - 1; i >= 0; i--) + { + if (!seenPids.Contains(target[i].ProcessId)) + { + target.RemoveAt(i); + } + } + + var selectedProcessTerminated = selectedProcessId.HasValue && selectedProcess == null; + return new ProcessListDeltaResult(selectedProcess, selectedProcessTerminated); + } + + private static void CopyProcessState(ProcessModel source, ProcessModel target) + { + target.Name = source.Name; + target.ExecutablePath = source.ExecutablePath; + target.CpuUsage = source.CpuUsage; + target.MemoryUsage = source.MemoryUsage; + target.Priority = source.Priority; + target.ProcessorAffinity = source.ProcessorAffinity; + target.MainWindowHandle = source.MainWindowHandle; + target.MainWindowTitle = source.MainWindowTitle; + target.HasVisibleWindow = source.HasVisibleWindow; + target.IsForeground = source.IsForeground; + target.Classification = source.Classification; + target.IsIdleServerDisabled = source.IsIdleServerDisabled; + target.IsRegistryPriorityEnabled = source.IsRegistryPriorityEnabled; + } + } +} diff --git a/Services/ProcessMonitorManagerService.cs b/Services/ProcessMonitorManagerService.cs index 792609a..6311a78 100644 --- a/Services/ProcessMonitorManagerService.cs +++ b/Services/ProcessMonitorManagerService.cs @@ -38,6 +38,8 @@ public class ProcessMonitorManagerService : IProcessMonitorManagerService private readonly IApplicationSettingsService settingsService; private readonly IProcessService processService; private readonly ICoreMaskService coreMaskService; + private readonly IAffinityApplyService affinityApplyService; + private readonly PowerPlanTransitionGate powerPlanTransitionGate; private readonly ILogger logger; private readonly IEnhancedLoggingService enhancedLogger; private readonly object lockObject = new(); @@ -71,6 +73,8 @@ public ProcessMonitorManagerService( IApplicationSettingsService settingsService, IProcessService processService, ICoreMaskService coreMaskService, + IAffinityApplyService affinityApplyService, + PowerPlanTransitionGate powerPlanTransitionGate, ILogger logger, IEnhancedLoggingService enhancedLogger) { @@ -81,6 +85,8 @@ public ProcessMonitorManagerService( this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService)); + this.affinityApplyService = affinityApplyService ?? throw new ArgumentNullException(nameof(affinityApplyService)); + this.powerPlanTransitionGate = powerPlanTransitionGate ?? throw new ArgumentNullException(nameof(powerPlanTransitionGate)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.enhancedLogger = enhancedLogger ?? throw new ArgumentNullException(nameof(enhancedLogger)); @@ -264,6 +270,19 @@ public async Task ForceDefaultPowerPlanAsync() await this.powerPlanChangeSemaphore.WaitAsync(); var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan(); + var decision = this.powerPlanTransitionGate.ShouldApply( + this.configuration.DefaultPowerPlanGuid, + currentPowerPlan?.Guid); + if (!decision.ShouldApply) + { + this.logger.LogDebug( + "Default power plan restore suppressed for {PowerPlanGuid}: {Reason}", + this.configuration.DefaultPowerPlanGuid, + decision.SuppressionReason); + return; + } + + this.powerPlanTransitionGate.RecordAttempt(this.configuration.DefaultPowerPlanGuid); var success = await this.powerPlanService.SetActivePowerPlanByGuidAsync( this.configuration.DefaultPowerPlanGuid, this.configuration.PreventDuplicatePowerPlanChanges); @@ -284,6 +303,12 @@ await this.notificationService.ShowPowerPlanChangeNotificationAsync( newPowerPlan?.Name ?? this.configuration.DefaultPowerPlanName, string.Empty); } + else + { + this.logger.LogWarning( + "Failed to restore default power plan {PowerPlanGuid}", + this.configuration.DefaultPowerPlanGuid); + } } catch (Exception ex) { @@ -505,6 +530,19 @@ private async Task ChangePowerPlanForProcess(ProcessModel process, ProcessPowerP await this.powerPlanChangeSemaphore.WaitAsync(); var currentPowerPlan = await this.powerPlanService.GetActivePowerPlan(); + var decision = this.powerPlanTransitionGate.ShouldApply( + association.PowerPlanGuid, + currentPowerPlan?.Guid); + if (!decision.ShouldApply) + { + this.logger.LogDebug( + "Power plan change suppressed for {PowerPlanGuid}: {Reason}", + association.PowerPlanGuid, + decision.SuppressionReason); + return; + } + + this.powerPlanTransitionGate.RecordAttempt(association.PowerPlanGuid); var success = await this.powerPlanService.SetActivePowerPlanByGuidAsync( association.PowerPlanGuid, this.configuration?.PreventDuplicatePowerPlanChanges ?? true); @@ -521,6 +559,14 @@ await this.notificationService.ShowPowerPlanChangeNotificationAsync( newPowerPlan?.Name ?? association.PowerPlanName, process.Name); } + else + { + this.logger.LogWarning( + "Failed to change power plan to {PowerPlanGuid} for process {ProcessName} (PID: {ProcessId})", + association.PowerPlanGuid, + process.Name, + process.ProcessId); + } } catch (Exception ex) { @@ -583,18 +629,30 @@ private async Task ApplyCoreMaskAndPriorityAsync(ProcessModel process, ProcessPo var affinity = coreMask.ToProcessorAffinity(); if (affinity > 0) { - await this.processService.SetProcessorAffinity(process, affinity); - this.processService.TrackAppliedMask(process.ProcessId, coreMask.Id); - this.coreMaskService.RegisterMaskApplication(process.ProcessId, coreMask.Id); - - this.logger.LogInformation( - "Applied CPU mask '{MaskName}' (affinity: 0x{Affinity:X}) to process {ProcessName} (PID: {ProcessId})", - coreMask.Name, affinity, process.Name, process.ProcessId); - - await this.enhancedLogger.LogProcessMonitoringEventAsync( - LogEventTypes.ProcessMonitoring.AssociationTriggered, - process.Name, process.ProcessId, - $"CPU mask '{coreMask.Name}' applied automatically from association"); + var result = await this.affinityApplyService.ApplyAsync(process, affinity); + if (!result.Success) + { + this.logger.LogWarning( + "Failed to apply CPU mask '{MaskName}' to process {ProcessName} (PID: {ProcessId}): {Message}", + coreMask.Name, + process.Name, + process.ProcessId, + result.Message); + } + else + { + this.processService.TrackAppliedMask(process.ProcessId, coreMask.Id); + this.coreMaskService.RegisterMaskApplication(process.ProcessId, coreMask.Id); + + this.logger.LogInformation( + "Applied CPU mask '{MaskName}' (affinity: 0x{Affinity:X}) to process {ProcessName} (PID: {ProcessId})", + coreMask.Name, affinity, process.Name, process.ProcessId); + + await this.enhancedLogger.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + process.Name, process.ProcessId, + $"CPU mask '{coreMask.Name}' applied automatically from association"); + } } } catch (Exception ex) diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index 57ec0b7..6b8304b 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -38,6 +38,9 @@ public class ProcessService : IProcessService private readonly ConcurrentDictionary cpuSetHandlers = new(); private readonly ILogger? logger; private readonly ISecurityService? securityService; + private readonly IForegroundProcessService? foregroundProcessService; + private readonly IProcessClassifier processClassifier; + private readonly IPassiveProcessErrorThrottle passiveProcessErrorThrottle; private readonly Func profilesDirectoryProvider; private string ProfilesDirectory => this.profilesDirectoryProvider(); @@ -51,10 +54,16 @@ public class ProcessService : IProcessService public ProcessService( ILogger? logger = null, ISecurityService? securityService = null, - Func? profilesDirectoryProvider = null) + Func? profilesDirectoryProvider = null, + IForegroundProcessService? foregroundProcessService = null, + IProcessClassifier? processClassifier = null, + IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null) { this.logger = logger; this.securityService = securityService; + this.foregroundProcessService = foregroundProcessService; + this.processClassifier = processClassifier ?? new ProcessClassifier(new ProcessFilterService()); + this.passiveProcessErrorThrottle = passiveProcessErrorThrottle ?? new PassiveProcessErrorThrottle(); this.profilesDirectoryProvider = profilesDirectoryProvider ?? (() => StoragePaths.ProfilesDirectory); StoragePaths.EnsureAppDataDirectories(); @@ -72,9 +81,10 @@ public async Task> GetProcessesAsync() { return await Task.Run(() => { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var processes = Process.GetProcesses() - .Select(this.CreateProcessModel) - .Where(p => p != null) + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() .OrderBy(p => p.Name); return new ObservableCollection(processes); @@ -139,40 +149,212 @@ private double CalculateCpuUsage(Process process) } public ProcessModel CreateProcessModel(Process process) + { + return this.CreateProcessModel(process, this.foregroundProcessService?.TryGetForegroundProcessId()); + } + + private ProcessModel? TryCreateProcessModel(Process process, int? foregroundProcessId) + { + try + { + return this.CreateProcessModel(process, foregroundProcessId); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + var processId = TryGetProcessId(process); + if (processId.HasValue) + { + this.CleanupProcessResources(processId.Value); + this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.Terminated, ex); + } + + return CreateMinimalProcessModel(process, processId, ProcessClassification.Terminated); + } + catch (Exception ex) when (IsPassiveProcessAccessException(ex)) + { + var processId = TryGetProcessId(process); + if (processId.HasValue) + { + this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.AccessDenied, ex); + } + + return CreateMinimalProcessModel(process, processId, ProcessClassification.ProtectedOrAccessDenied); + } + catch (Exception ex) + { + var processId = TryGetProcessId(process); + if (processId.HasValue) + { + this.LogPassiveProcessReadFailure(processId.Value, PassiveProcessErrorKind.Unknown, ex); + } + + return CreateMinimalProcessModel(process, processId, ProcessClassification.Unknown); + } + } + + private ProcessModel CreateProcessModel(Process process, int? foregroundProcessId) { var model = new ProcessModel(); + var accessDenied = false; + var terminated = false; + try { model.ProcessId = process.Id; + } + catch + { + model.Classification = ProcessClassification.Unknown; + return model; + } + + try + { model.Name = process.ProcessName; - model.MemoryUsage = process.PrivateMemorySize64; - model.Priority = process.PriorityClass; - model.ProcessorAffinity = (long)process.ProcessorAffinity; - model.CpuUsage = this.CalculateCpuUsage(process); + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + model.Name = $"PID_{model.ProcessId}"; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + model.Name = $"PID_{model.ProcessId}"; + } - // Capture window information - model.MainWindowHandle = process.MainWindowHandle; - model.MainWindowTitle = process.MainWindowTitle ?? string.Empty; - model.HasVisibleWindow = model.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(model.MainWindowTitle); + if (!terminated) + { + try + { + if (process.HasExited) + { + terminated = true; + } + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + } - // Try to get executable path + if (!terminated) + { try { - model.ExecutablePath = process.MainModule?.FileName ?? string.Empty; + model.MemoryUsage = process.PrivateMemorySize64; } - catch + catch (Exception ex) when (IsAccessDeniedException(ex)) { - model.ExecutablePath = string.Empty; + accessDenied = true; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } + + if (!terminated) + { + try + { + model.Priority = process.PriorityClass; + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + model.MainWindowHandle = IntPtr.Zero; + model.MainWindowTitle = string.Empty; + model.HasVisibleWindow = false; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsPassiveProcessAccessException(ex)) + { + accessDenied = true; + model.MainWindowHandle = IntPtr.Zero; + model.MainWindowTitle = string.Empty; + model.HasVisibleWindow = false; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } + } + + if (!terminated) + { + try + { + model.ProcessorAffinity = (long)process.ProcessorAffinity; + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } + } + + if (!terminated) + { + model.CpuUsage = this.CalculateCpuUsage(process); + } + + if (!terminated) + { + try + { + model.MainWindowHandle = process.MainWindowHandle; + model.MainWindowTitle = process.MainWindowTitle ?? string.Empty; + model.HasVisibleWindow = model.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(model.MainWindowTitle); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } + } + + if (!terminated) + { + try + { + model.ExecutablePath = process.MainModule?.FileName ?? string.Empty; + } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + accessDenied = true; + model.ExecutablePath = string.Empty; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsPassiveProcessAccessException(ex)) + { + accessDenied = true; + model.ExecutablePath = string.Empty; + this.LogPassiveProcessReadFailure(model.ProcessId, PassiveProcessErrorKind.AccessDenied, ex); + } + catch (Exception ex) when (IsTerminatedProcessException(ex)) + { + terminated = true; + } } } - catch + + if (terminated) { - // Process may have terminated or access denied - // Return a minimal model - model.ProcessId = process.Id; - model.Name = process.ProcessName; + this.CleanupProcessResources(model.ProcessId); } + this.ApplyProcessClassification(model, foregroundProcessId, accessDenied, terminated); return model; } @@ -384,22 +566,128 @@ await Task.Run(() => process.MainWindowHandle = p.MainWindowHandle; process.MainWindowTitle = p.MainWindowTitle ?? string.Empty; process.HasVisibleWindow = process.MainWindowHandle != IntPtr.Zero && !string.IsNullOrWhiteSpace(process.MainWindowTitle); + this.ApplyProcessClassification( + process, + this.foregroundProcessService?.TryGetForegroundProcessId(), + accessDenied: false, + terminated: false); } catch (ArgumentException) { // Process with the specified ID does not exist this.CleanupProcessResources(process.ProcessId); + this.ApplyProcessClassification(process, null, accessDenied: false, terminated: true); throw new InvalidOperationException("Process no longer exists"); } + catch (Exception ex) when (IsAccessDeniedException(ex)) + { + this.ApplyProcessClassification(process, null, accessDenied: true, terminated: false); + throw new InvalidOperationException("Access denied while refreshing process information.", ex); + } catch (Exception ex) when (ex.Message.Contains("exited") || ex.Message.Contains("terminated")) { // Process has terminated this.CleanupProcessResources(process.ProcessId); + this.ApplyProcessClassification(process, null, accessDenied: false, terminated: true); throw new InvalidOperationException("Process has terminated"); } }).ConfigureAwait(false); } + private void ApplyProcessClassification( + ProcessModel process, + int? foregroundProcessId, + bool accessDenied, + bool terminated) + { + process.IsForeground = foregroundProcessId == process.ProcessId && !accessDenied && !terminated; + process.Classification = this.processClassifier.Classify( + process, + new ProcessClassificationContext(foregroundProcessId, accessDenied, terminated)); + } + + private void LogPassiveProcessReadFailure(int processId, PassiveProcessErrorKind errorKind, Exception exception) + { + if (this.passiveProcessErrorThrottle.ShouldLog(processId, errorKind)) + { + this.logger?.LogDebug( + exception, + "Passive process read returned {ErrorKind} for PID {ProcessId}", + errorKind, + processId); + } + } + + internal static bool IsPassiveProcessAccessException(Exception exception) + { + return IsAccessDeniedException(exception) || + exception is Win32Exception { NativeErrorCode: 299 } || + exception.Message.Contains("enumerate the process modules", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("access modules", StringComparison.OrdinalIgnoreCase) || + exception.Message.Contains("ReadProcessMemory", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsAccessDeniedException(Exception exception) + { + return exception is UnauthorizedAccessException || + exception is Win32Exception { NativeErrorCode: 5 }; + } + + private static bool IsTerminatedProcessException(Exception exception) + { + return exception is ArgumentException || + (exception is InvalidOperationException invalidOperationException && + (invalidOperationException.Message.Contains("exited", StringComparison.OrdinalIgnoreCase) || + invalidOperationException.Message.Contains("terminated", StringComparison.OrdinalIgnoreCase) || + invalidOperationException.Message.Contains("no longer exists", StringComparison.OrdinalIgnoreCase))); + } + + private static int? TryGetProcessId(Process process) + { + try + { + return process.Id; + } + catch + { + return null; + } + } + + private static ProcessModel? CreateMinimalProcessModel( + Process process, + int? processId, + ProcessClassification classification) + { + if (!processId.HasValue) + { + return null; + } + + return new ProcessModel + { + ProcessId = processId.Value, + Name = TryGetProcessName(process, processId.Value), + ExecutablePath = string.Empty, + MainWindowHandle = IntPtr.Zero, + MainWindowTitle = string.Empty, + HasVisibleWindow = false, + Classification = classification, + }; + } + + private static string TryGetProcessName(Process process, int processId) + { + try + { + return process.ProcessName; + } + catch + { + return $"PID_{processId}"; + } + } + /// /// Cleanup resources associated with a process. /// @@ -518,9 +806,10 @@ public async Task> GetProcessesByNameAsync(string exec { try { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var processes = Process.GetProcessesByName(executableName) - .Select(this.CreateProcessModel) - .Where(p => p != null); + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType(); return processes; } @@ -541,9 +830,11 @@ public async Task> GetProcessesWithPathsAsync() { return await Task.Run(() => { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var processes = Process.GetProcesses() - .Select(this.CreateProcessModel) - .Where(p => p != null && !string.IsNullOrEmpty(p.ExecutablePath)) + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() + .Where(p => !string.IsNullOrEmpty(p.ExecutablePath)) .OrderBy(p => p.Name); return processes; @@ -554,9 +845,11 @@ public async Task> GetActiveApplicationsAsync { return await Task.Run(() => { + var foregroundProcessId = this.foregroundProcessService?.TryGetForegroundProcessId(); var processes = Process.GetProcesses() - .Select(this.CreateProcessModel) - .Where(p => p != null && p.HasVisibleWindow) + .Select(process => this.TryCreateProcessModel(process, foregroundProcessId)) + .OfType() + .Where(p => p.HasVisibleWindow) .OrderBy(p => p.Name); return new ObservableCollection(processes); diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index a93caeb..e311e55 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -99,11 +99,17 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo private static IServiceCollection ConfigureCoreSystemServices(this IServiceCollection services) { // Core system interaction services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // CoreMaskService needs IServiceProvider for checking profile references diff --git a/Services/SystemTrayService.cs b/Services/SystemTrayService.cs index bd3b936..bcb6143 100644 --- a/Services/SystemTrayService.cs +++ b/Services/SystemTrayService.cs @@ -134,7 +134,7 @@ private void CreateContextMenu() this.performanceMenuItem.Click += this.OnPerformanceDashboardClick; this.contextMenu.Items.Add(this.performanceMenuItem); - this.monitoringToggleMenuItem = new ToolStripMenuItem("Pause Monitoring"); + this.monitoringToggleMenuItem = new ToolStripMenuItem("Pause Automation Monitoring"); this.monitoringToggleMenuItem.Click += this.OnMonitoringToggleClick; this.contextMenu.Items.Add(this.monitoringToggleMenuItem); @@ -157,7 +157,7 @@ private void CreateContextMenu() this.contextMenu.Items.Add(this.selectedProcessMenuItem); // Quick apply command - this.quickApplyMenuItem = new ToolStripMenuItem("Quick Apply to Selected Process") + this.quickApplyMenuItem = new ToolStripMenuItem("Apply Pending Settings to Selected Process") { Enabled = false, }; @@ -237,13 +237,13 @@ public void UpdateContextMenu(string? selectedProcessName = null, bool hasSelect { this.selectedProcessMenuItem.Text = $"Selected: {selectedProcessName}"; this.quickApplyMenuItem.Enabled = true; - this.quickApplyMenuItem.Text = $"Quick Apply to {selectedProcessName}"; + this.quickApplyMenuItem.Text = $"Apply Pending Settings to {selectedProcessName}"; } else { this.selectedProcessMenuItem.Text = "No process selected"; this.quickApplyMenuItem.Enabled = false; - this.quickApplyMenuItem.Text = "Quick Apply to Selected Process"; + this.quickApplyMenuItem.Text = "Apply Pending Settings to Selected Process"; } } @@ -307,7 +307,7 @@ public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true if (this.monitoringToggleMenuItem != null) { - this.monitoringToggleMenuItem.Text = isMonitoring ? "Pause Monitoring" : "Resume Monitoring"; + this.monitoringToggleMenuItem.Text = isMonitoring ? "Pause Automation Monitoring" : "Resume Automation Monitoring"; this.monitoringToggleMenuItem.Enabled = isWmiAvailable; } @@ -317,8 +317,8 @@ public void UpdateMonitoringStatus(bool isMonitoring, bool isWmiAvailable = true this.UpdateTrayIcon(iconState); // Update tooltip - var status = !isWmiAvailable ? "WMI Error" : - isMonitoring ? "Monitoring Active" : "Monitoring Disabled"; + var status = !isWmiAvailable ? "Automation WMI Error" : + isMonitoring ? "Automation Active" : "Automation Disabled"; this.UpdateTooltip($"ThreadPilot - {status}"); } diff --git a/Services/VirtualizedProcessService.cs b/Services/VirtualizedProcessService.cs index 0b4b54c..ff2bc5b 100644 --- a/Services/VirtualizedProcessService.cs +++ b/Services/VirtualizedProcessService.cs @@ -274,7 +274,7 @@ private void BackgroundPreloadCallback(object? state) { TaskSafety.FireAndForget(this.BackgroundPreloadCallbackAsync(), ex => { - this.logger.LogWarning(ex, "Background process refresh failed"); + this.logger.LogDebug(ex, "Background process refresh failed"); }); } diff --git a/Services/WindowsForegroundWindowProvider.cs b/Services/WindowsForegroundWindowProvider.cs new file mode 100644 index 0000000..57fc17e --- /dev/null +++ b/Services/WindowsForegroundWindowProvider.cs @@ -0,0 +1,78 @@ +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace ThreadPilot.Services +{ + using System; + using System.Runtime.InteropServices; + + public sealed class WindowsForegroundWindowProvider : IForegroundWindowProvider + { + private const int DwmwaCloaked = 14; + + public bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot) + { + snapshot = default; + + var windowHandle = GetForegroundWindow(); + if (windowHandle == IntPtr.Zero) + { + return false; + } + + _ = GetWindowThreadProcessId(windowHandle, out var processId); + if (processId == 0) + { + return false; + } + + snapshot = new ForegroundWindowSnapshot( + windowHandle, + unchecked((int)processId), + IsWindowVisible(windowHandle), + IsWindowCloaked(windowHandle)); + return true; + } + + private static bool IsWindowCloaked(IntPtr windowHandle) + { + var result = DwmGetWindowAttribute( + windowHandle, + DwmwaCloaked, + out int cloaked, + Marshal.SizeOf()); + + return result == 0 && cloaked != 0; + } + + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmGetWindowAttribute( + IntPtr hwnd, + int dwAttribute, + out int pvAttribute, + int cbAttribute); + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs new file mode 100644 index 0000000..af592a6 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/AffinityApplyServiceTests.cs @@ -0,0 +1,211 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class AffinityApplyServiceTests + { + [Fact] + public async Task ApplyAsync_WhenVerifiedMaskMatches_ReturnsSuccess() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .Returns(Task.CompletedTask); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .Callback(() => process.ProcessorAffinity = 1) + .Returns(Task.CompletedTask); + + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.True(result.Success); + Assert.Equal(1, result.RequestedMask); + Assert.Equal(1, result.VerifiedMask); + Assert.Equal(AffinityApplyFailureReason.None, result.FailureReason); + } + + [Fact] + public async Task ApplyAsync_WhenProcessIsTerminated_ReturnsFailureWithoutApplying() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: false); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.ProcessTerminated, result.FailureReason); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyAsync_WhenAccessDenied_ReturnsAccessDeniedFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .ThrowsAsync(new InvalidOperationException("Access denied while setting processor affinity.")); + + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.AccessDenied, result.FailureReason); + Assert.Equal(3, result.VerifiedMask); + } + + [Fact] + public async Task ApplyAsync_WhenVerifiedMaskDiffers_ReturnsMismatchFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .Returns(Task.CompletedTask); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .Callback(() => process.ProcessorAffinity = 2) + .Returns(Task.CompletedTask); + + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.VerificationMismatch, result.FailureReason); + Assert.Equal(1, result.RequestedMask); + Assert.Equal(2, result.VerifiedMask); + } + + [Fact] + public async Task ApplyAsync_WhenMaskIsZero_ReturnsInvalidMaskFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 0); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason); + } + + [Fact] + public async Task ApplyAsync_WhenTopologyRejectsMask_ReturnsInvalidMaskFailure() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + var topologyService = new Mock(MockBehavior.Strict); + topologyService.Setup(service => service.IsAffinityMaskValid(8)).Returns(false); + var service = CreateService(processService, topologyService); + + var result = await service.ApplyAsync(process, 8); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.InvalidMask, result.FailureReason); + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApplyAsync_WhenProcessStateCheckIsAccessDenied_StillAttemptsApply() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = new Mock(MockBehavior.Strict); + processService + .Setup(service => service.IsProcessStillRunning(process)) + .ThrowsAsync(new UnauthorizedAccessException("Access denied.")); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .Returns(Task.CompletedTask); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .Callback(() => process.ProcessorAffinity = 1) + .Returns(Task.CompletedTask); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.True(result.Success); + processService.Verify(service => service.SetProcessorAffinity(process, 1), Times.Once); + } + + [Fact] + public async Task ApplyAsync_WhenRefreshAfterApplyIsAccessDenied_ReportsVerificationMismatch() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .Returns(Task.CompletedTask); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .ThrowsAsync(new UnauthorizedAccessException("Access denied.")); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.VerificationMismatch, result.FailureReason); + Assert.Equal(3, result.VerifiedMask); + } + + [Fact] + public async Task ApplyAsync_WhenApplyThrowsUnexpectedError_ReturnsApplyFailed() + { + var process = new ProcessModel { ProcessId = 42, Name = "Game", ProcessorAffinity = 3 }; + var processService = CreateProcessService(processStillRunning: true); + processService + .Setup(service => service.SetProcessorAffinity(process, 1)) + .ThrowsAsync(new InvalidOperationException("Driver rejected request.")); + processService + .Setup(service => service.RefreshProcessInfo(process)) + .Returns(Task.CompletedTask); + var service = CreateService(processService); + + var result = await service.ApplyAsync(process, 1); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyFailureReason.ApplyFailed, result.FailureReason); + Assert.Equal(3, result.VerifiedMask); + } + + private static AffinityApplyService CreateService(Mock processService) + { + var topologyService = new Mock(MockBehavior.Loose); + topologyService.Setup(service => service.IsAffinityMaskValid(It.IsAny())).Returns(true); + + return CreateService(processService, topologyService); + } + + private static AffinityApplyService CreateService( + Mock processService, + Mock topologyService) + { + return new AffinityApplyService( + processService.Object, + topologyService.Object, + NullLogger.Instance); + } + + private static Mock CreateProcessService(bool processStillRunning) + { + var processService = new Mock(MockBehavior.Strict); + processService + .Setup(service => service.IsProcessStillRunning(It.IsAny())) + .ReturnsAsync(processStillRunning); + return processService; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs new file mode 100644 index 0000000..f78bca4 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/AppRefreshPolicyTests.cs @@ -0,0 +1,44 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class AppRefreshPolicyTests + { + [Theory] + [InlineData(AppActivityState.ForegroundProcessView, true, true, true, true, true, true)] + [InlineData(AppActivityState.ForegroundOtherTab, false, false, false, true, true, true)] + [InlineData(AppActivityState.Minimized, false, false, false, false, false, true)] + [InlineData(AppActivityState.TrayHidden, false, false, false, false, false, true)] + public void Evaluate_ReturnsExpectedRefreshDecision( + AppActivityState state, + bool processUiRefreshEnabled, + bool immediateProcessRefresh, + bool virtualizedPreloadEnabled, + bool performanceUiMonitoringEnabled, + bool powerPlanUiRefreshEnabled, + bool backgroundAutomationEnabled) + { + var decision = AppRefreshPolicy.Evaluate(state); + + Assert.Equal(processUiRefreshEnabled, decision.ProcessUiRefreshEnabled); + Assert.Equal(immediateProcessRefresh, decision.ImmediateProcessRefresh); + Assert.Equal(virtualizedPreloadEnabled, decision.VirtualizedPreloadEnabled); + Assert.Equal(performanceUiMonitoringEnabled, decision.PerformanceUiMonitoringEnabled); + Assert.Equal(powerPlanUiRefreshEnabled, decision.PowerPlanUiRefreshEnabled); + Assert.Equal(backgroundAutomationEnabled, decision.BackgroundAutomationEnabled); + } + + [Fact] + public void Evaluate_WhenStateIsUnknown_KeepsBackgroundAutomationOnly() + { + var decision = AppRefreshPolicy.Evaluate((AppActivityState)999); + + Assert.False(decision.ProcessUiRefreshEnabled); + Assert.False(decision.ImmediateProcessRefresh); + Assert.False(decision.VirtualizedPreloadEnabled); + Assert.False(decision.PerformanceUiMonitoringEnabled); + Assert.False(decision.PowerPlanUiRefreshEnabled); + Assert.True(decision.BackgroundAutomationEnabled); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs new file mode 100644 index 0000000..ec31202 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ForegroundProcessServiceTests.cs @@ -0,0 +1,68 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + + public sealed class ForegroundProcessServiceTests + { + [Fact] + public void TryGetForegroundProcessId_ReturnsPidFromVisibleForegroundWindow() + { + var provider = new FakeForegroundWindowProvider( + new ForegroundWindowSnapshot(new IntPtr(42), 1234, true, false)); + var service = new ForegroundProcessService(provider, NullLogger.Instance); + + var result = service.TryGetForegroundProcessId(); + + Assert.Equal(1234, result); + } + + [Theory] + [InlineData(0, true, false)] + [InlineData(1234, false, false)] + [InlineData(1234, true, true)] + public void TryGetForegroundProcessId_ReturnsNullForInvalidForegroundWindow(int processId, bool isVisible, bool isCloaked) + { + var provider = new FakeForegroundWindowProvider( + new ForegroundWindowSnapshot(new IntPtr(42), processId, isVisible, isCloaked)); + var service = new ForegroundProcessService(provider, NullLogger.Instance); + + var result = service.TryGetForegroundProcessId(); + + Assert.Null(result); + } + + [Fact] + public void TryGetForegroundProcessId_ReturnsNullWhenProviderFails() + { + var provider = new FakeForegroundWindowProvider(null); + var service = new ForegroundProcessService(provider, NullLogger.Instance); + + var result = service.TryGetForegroundProcessId(); + + Assert.Null(result); + } + + private sealed class FakeForegroundWindowProvider : IForegroundWindowProvider + { + private readonly ForegroundWindowSnapshot? snapshot; + + public FakeForegroundWindowProvider(ForegroundWindowSnapshot? snapshot) + { + this.snapshot = snapshot; + } + + public bool TryGetForegroundWindow(out ForegroundWindowSnapshot snapshot) + { + if (this.snapshot == null) + { + snapshot = default; + return false; + } + + snapshot = this.snapshot.Value; + return true; + } + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs new file mode 100644 index 0000000..26ce17f --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PassiveProcessErrorThrottleTests.cs @@ -0,0 +1,59 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class PassiveProcessErrorThrottleTests + { + [Fact] + public void ShouldLog_ReturnsFalseForRepeatedErrorInsideTtl() + { + var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero); + var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied)); + Assert.False(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied)); + } + + [Fact] + public void ShouldLog_ReturnsTrueAfterTtlExpires() + { + var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero); + var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied)); + now = now.AddMinutes(2); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied)); + } + + [Fact] + public void ShouldLog_TracksPidAndErrorKindSeparately() + { + var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero); + var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.AccessDenied)); + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Terminated)); + Assert.True(throttle.ShouldLog(43, PassiveProcessErrorKind.AccessDenied)); + } + + [Fact] + public void ShouldLog_WhenElapsedTimeEqualsTtl_ReturnsTrue() + { + var now = new DateTimeOffset(2026, 5, 9, 12, 0, 0, TimeSpan.Zero); + var throttle = new PassiveProcessErrorThrottle(TimeSpan.FromMinutes(1), () => now); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Unknown)); + now = now.AddMinutes(1); + + Assert.True(throttle.ShouldLog(42, PassiveProcessErrorKind.Unknown)); + } + + [Fact] + public void Constructor_WhenTtlIsZero_Throws() + { + Assert.Throws(() => + new PassiveProcessErrorThrottle(TimeSpan.Zero, () => DateTimeOffset.UtcNow)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs new file mode 100644 index 0000000..5c3071d --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanTransitionGateTests.cs @@ -0,0 +1,89 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Services; + + public sealed class PowerPlanTransitionGateTests + { + [Fact] + public void ShouldApply_WhenTargetWasNotRequested_ReturnsTrue() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now); + + var decision = gate.ShouldApply("plan-game", "balanced"); + + Assert.True(decision.ShouldApply); + Assert.Equal(PowerPlanTransitionSuppressionReason.None, decision.SuppressionReason); + } + + [Fact] + public void ShouldApply_WhenTargetIsAlreadyActive_ReturnsFalse() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now); + + var decision = gate.ShouldApply("plan-game", "plan-game"); + + Assert.False(decision.ShouldApply); + Assert.Equal(PowerPlanTransitionSuppressionReason.AlreadyActive, decision.SuppressionReason); + } + + [Fact] + public void ShouldApply_WhenSameTargetWasRecentlyRequested_ReturnsFalse() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now); + + Assert.True(gate.ShouldApply("plan-game", "balanced").ShouldApply); + gate.RecordAttempt("plan-game"); + now = now.AddMilliseconds(500); + + var decision = gate.ShouldApply("plan-game", "balanced"); + + Assert.False(decision.ShouldApply); + Assert.Equal(PowerPlanTransitionSuppressionReason.RecentDuplicateRequest, decision.SuppressionReason); + } + + [Fact] + public void ShouldApply_WhenDifferentTargetArrives_UsesLatestTarget() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now); + + gate.RecordAttempt("plan-game"); + now = now.AddMilliseconds(500); + + var decision = gate.ShouldApply("plan-default", "plan-game"); + + Assert.True(decision.ShouldApply); + } + + [Fact] + public void ShouldApply_WhenDuplicateWindowExpires_ReturnsTrue() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => now); + + gate.RecordAttempt("plan-game"); + now = now.AddSeconds(3); + + var decision = gate.ShouldApply("plan-game", "balanced"); + + Assert.True(decision.ShouldApply); + Assert.Equal(PowerPlanTransitionSuppressionReason.None, decision.SuppressionReason); + } + + [Fact] + public void Constructor_WhenDuplicateWindowIsNegative_UsesZeroWindow() + { + var now = DateTimeOffset.Parse("2026-05-09T10:00:00Z"); + var gate = new PowerPlanTransitionGate(TimeSpan.FromSeconds(-1), () => now); + + gate.RecordAttempt("plan-game"); + + var decision = gate.ShouldApply("plan-game", "balanced"); + + Assert.True(decision.ShouldApply); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs new file mode 100644 index 0000000..fe48f47 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessClassifierTests.cs @@ -0,0 +1,149 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessClassifierTests + { + [Fact] + public void Classify_ReturnsForegroundAppForForegroundPid() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "Game", + HasVisibleWindow = true, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(10)); + + Assert.Equal(ProcessClassification.ForegroundApp, result); + } + + [Fact] + public void Classify_ReturnsVisibleWindowAppForVisibleNonForegroundProcess() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "Editor", + HasVisibleWindow = true, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(20)); + + Assert.Equal(ProcessClassification.VisibleWindowApp, result); + } + + [Theory] + [InlineData("svchost")] + [InlineData("svchost.exe")] + public void Classify_ReturnsSystemForNormalizedSystemProcessNames(string processName) + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = processName, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(null)); + + Assert.Equal(ProcessClassification.System, result); + } + + [Fact] + public void Classify_ReturnsProtectedOrAccessDeniedWhenAccessWasDenied() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "ProtectedProcess", + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(null, AccessDenied: true)); + + Assert.Equal(ProcessClassification.ProtectedOrAccessDenied, result); + } + + [Fact] + public void Classify_ReturnsTerminatedWhenProcessTerminated() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "ClosedProcess", + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(null, Terminated: true)); + + Assert.Equal(ProcessClassification.Terminated, result); + } + + [Fact] + public void Classify_ReturnsBackgroundUserForNonSystemProcessWithoutWindow() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "Worker", + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(null)); + + Assert.Equal(ProcessClassification.BackgroundUser, result); + } + + [Fact] + public void Classify_TerminatedTakesPrecedenceOverForegroundAndSystem() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "svchost", + HasVisibleWindow = true, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(10, Terminated: true)); + + Assert.Equal(ProcessClassification.Terminated, result); + } + + [Fact] + public void Classify_AccessDeniedTakesPrecedenceOverForegroundAndWindow() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = "ProtectedWindow", + HasVisibleWindow = true, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(10, AccessDenied: true)); + + Assert.Equal(ProcessClassification.ProtectedOrAccessDenied, result); + } + + [Fact] + public void Classify_ReturnsUnknownWhenNameIsMissing() + { + var classifier = new ProcessClassifier(new ProcessFilterService()); + var process = new ProcessModel + { + ProcessId = 10, + Name = string.Empty, + }; + + var result = classifier.Classify(process, new ProcessClassificationContext(null)); + + Assert.Equal(ProcessClassification.Unknown, result); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs new file mode 100644 index 0000000..2f203db --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessListDeltaUpdaterTests.cs @@ -0,0 +1,134 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using System.Diagnostics; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ProcessListDeltaUpdaterTests + { + [Fact] + public void ApplyDelta_PreservesExistingInstancesAndUpdatesProperties() + { + var existing = new ProcessModel + { + ProcessId = 42, + Name = "ThreadPilot", + CpuUsage = 1, + MemoryUsage = 100, + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 1, + }; + var processes = new ObservableCollection { existing }; + var snapshot = new[] + { + new ProcessModel + { + ProcessId = 42, + Name = "ThreadPilot", + CpuUsage = 7, + MemoryUsage = 500, + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 3, + HasVisibleWindow = true, + IsForeground = true, + Classification = ProcessClassification.ForegroundApp, + MainWindowTitle = "ThreadPilot - Processes", + }, + }; + + var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 42); + + Assert.Same(existing, processes[0]); + Assert.Same(existing, result.SelectedProcess); + Assert.False(result.SelectedProcessTerminated); + Assert.Equal(7, existing.CpuUsage); + Assert.Equal(500, existing.MemoryUsage); + Assert.Equal(ProcessPriorityClass.High, existing.Priority); + Assert.Equal(3, existing.ProcessorAffinity); + Assert.True(existing.HasVisibleWindow); + Assert.True(existing.IsForeground); + Assert.Equal(ProcessClassification.ForegroundApp, existing.Classification); + Assert.Equal("ThreadPilot - Processes", existing.MainWindowTitle); + } + + [Fact] + public void ApplyDelta_AddsNewProcessesAndRemovesDeadProcesses() + { + var removed = new ProcessModel { ProcessId = 10, Name = "Dead" }; + var kept = new ProcessModel { ProcessId = 20, Name = "Kept" }; + var processes = new ObservableCollection { removed, kept }; + var snapshot = new[] + { + new ProcessModel { ProcessId = 20, Name = "Kept" }, + new ProcessModel { ProcessId = 30, Name = "New" }, + }; + + var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 20); + + Assert.Equal(2, processes.Count); + Assert.DoesNotContain(processes, p => p.ProcessId == 10); + Assert.Contains(processes, p => p.ProcessId == 30); + Assert.Same(kept, result.SelectedProcess); + Assert.False(result.SelectedProcessTerminated); + } + + [Fact] + public void ApplyDelta_ReportsTerminatedSelection() + { + var selected = new ProcessModel { ProcessId = 10, Name = "Dead" }; + var processes = new ObservableCollection { selected }; + + var result = ProcessListDeltaUpdater.ApplyDelta(processes, Array.Empty(), 10); + + Assert.Empty(processes); + Assert.Null(result.SelectedProcess); + Assert.True(result.SelectedProcessTerminated); + } + + [Fact] + public void ApplyDelta_WhenSnapshotContainsDuplicatePid_UsesLatestSnapshot() + { + var existing = new ProcessModel { ProcessId = 42, Name = "Old" }; + var processes = new ObservableCollection { existing }; + var snapshot = new[] + { + new ProcessModel { ProcessId = 42, Name = "First", CpuUsage = 1 }, + new ProcessModel { ProcessId = 42, Name = "Latest", CpuUsage = 9 }, + }; + + var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 42); + + Assert.Single(processes); + Assert.Same(existing, processes[0]); + Assert.Same(existing, result.SelectedProcess); + Assert.Equal("Latest", existing.Name); + Assert.Equal(9, existing.CpuUsage); + } + + [Fact] + public void ApplyDelta_PreservesSelectionDuringAddRemoveChurn() + { + var selected = new ProcessModel { ProcessId = 20, Name = "Selected" }; + var processes = new ObservableCollection + { + new() { ProcessId = 10, Name = "Removed" }, + selected, + }; + + var snapshot = new[] + { + new ProcessModel { ProcessId = 20, Name = "Selected Updated" }, + new ProcessModel { ProcessId = 30, Name = "Added" }, + }; + + var result = ProcessListDeltaUpdater.ApplyDelta(processes, snapshot, 20); + + Assert.Equal(2, processes.Count); + Assert.Same(selected, result.SelectedProcess); + Assert.False(result.SelectedProcessTerminated); + Assert.DoesNotContain(processes, process => process.ProcessId == 10); + Assert.Contains(processes, process => process.ProcessId == 30); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index 27e5c28..0842d8d 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -25,13 +25,15 @@ public async Task StartAsync_LoadsConfiguration_AndStartsMonitoring() var notificationService = CreateNotificationService(); var processService = CreateProcessService(); var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); var manager = CreateService( processMonitor, associationService, powerPlanService, notificationService, processService, - coreMaskService); + coreMaskService, + affinityApplyService); await manager.StartAsync(); @@ -68,13 +70,15 @@ public async Task StartAsync_SelectsHighestPriorityAssociation() var notificationService = CreateNotificationService(); var processService = CreateProcessService(); var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); var manager = CreateService( processMonitor, associationService, powerPlanService, notificationService, processService, - coreMaskService); + coreMaskService, + affinityApplyService); await manager.StartAsync(); @@ -104,13 +108,15 @@ public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation() var notificationService = CreateNotificationService(); var processService = CreateProcessService(); var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); var manager = CreateService( processMonitor, associationService, powerPlanService, notificationService, processService, - coreMaskService); + coreMaskService, + affinityApplyService); await manager.StartAsync(); processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" }); @@ -123,6 +129,90 @@ public async Task ProcessStarted_WithDelay_TriggersSingleReevaluation() Times.Once); } + [Fact] + public async Task ProcessStarted_SamePlanRequest_IsSuppressedWithinDuplicateWindow() + { + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration + { + PowerPlanChangeDelayMs = 0, + Associations = + { + new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 5 }, + }, + }; + + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" }); + processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 11, Name = "game" }); + + await Task.Delay(100); + + powerPlanService.Verify( + x => x.SetActivePowerPlanByGuidAsync("plan-game", true), + Times.Once); + } + + [Fact] + public async Task ProcessStarted_WhenPowerPlanChangeFails_DoesNotRetrySamePlanImmediately() + { + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration + { + PowerPlanChangeDelayMs = 0, + Associations = + { + new ProcessPowerPlanAssociation("game", "plan-game", "Game") { Priority = 5 }, + }, + }; + + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + powerPlanService + .Setup(x => x.SetActivePowerPlanByGuidAsync("plan-game", true)) + .ReturnsAsync(false); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 10, Name = "game" }); + processMonitor.RaiseProcessStarted(new ProcessModel { ProcessId = 11, Name = "game" }); + + await Task.Delay(100); + + powerPlanService.Verify( + x => x.SetActivePowerPlanByGuidAsync("plan-game", true), + Times.Once); + notificationService.Verify( + x => x.ShowPowerPlanChangeNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured() { @@ -150,13 +240,15 @@ public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured() var notificationService = CreateNotificationService(); var processService = CreateProcessService(); var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); var manager = CreateService( processMonitor, associationService, powerPlanService, notificationService, processService, - coreMaskService); + coreMaskService, + affinityApplyService); await manager.StartAsync(); await manager.StopAsync(); @@ -168,6 +260,60 @@ public async Task StopAsync_RestoresDefaultPowerPlan_WhenConfigured() coreMaskService.Verify(x => x.UnregisterMaskApplication(21), Times.Once); } + [Fact] + public async Task ProcessStarted_AppliesConfiguredCoreMaskForMatchingProcess() + { + var process = new ProcessModel { ProcessId = 31, Name = "game" }; + var processMonitor = new FakeProcessMonitorService(); + + var configuration = new ProcessMonitorConfiguration + { + PowerPlanChangeDelayMs = 0, + Associations = + { + new ProcessPowerPlanAssociation("game", "plan-game", "Game") + { + CoreMaskId = "mask-game", + Priority = 5, + }, + }, + }; + + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + processService.Setup(x => x.TrackAppliedMask(31, "mask-game")); + var coreMaskService = CreateCoreMaskService(); + coreMaskService.SetupGet(x => x.AvailableMasks).Returns(new ObservableCollection + { + new() + { + Id = "mask-game", + Name = "Game Mask", + BoolMask = new ObservableCollection { true, false }, + }, + }); + coreMaskService.Setup(x => x.RegisterMaskApplication(31, "mask-game")); + var affinityApplyService = CreateAffinityApplyService(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + affinityApplyService.Verify(x => x.ApplyAsync(process, 1), Times.Once); + processService.Verify(x => x.TrackAppliedMask(31, "mask-game"), Times.Once); + coreMaskService.Verify(x => x.RegisterMaskApplication(31, "mask-game"), Times.Once); + } + [Fact] public async Task Dispose_CompletesOnBlockingSynchronizationContext() { @@ -185,13 +331,15 @@ public async Task Dispose_CompletesOnBlockingSynchronizationContext() var notificationService = CreateNotificationService(); var processService = CreateProcessService(); var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); var manager = CreateService( processMonitor, associationService, powerPlanService, notificationService, processService, - coreMaskService); + coreMaskService, + affinityApplyService); await manager.StartAsync(); @@ -229,7 +377,8 @@ private static ProcessMonitorManagerService CreateService( Mock powerPlanService, Mock notificationService, Mock processService, - Mock coreMaskService) + Mock coreMaskService, + Mock affinityApplyService) { var enhancedLogger = new Mock(MockBehavior.Loose); enhancedLogger @@ -253,6 +402,8 @@ private static ProcessMonitorManagerService CreateService( settingsService.Object, processService.Object, coreMaskService.Object, + affinityApplyService.Object, + new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), NullLogger.Instance, enhancedLogger.Object); } @@ -303,6 +454,16 @@ private static Mock CreateCoreMaskService() return coreMaskService; } + private static Mock CreateAffinityApplyService() + { + var affinityApplyService = new Mock(MockBehavior.Strict); + affinityApplyService + .Setup(x => x.ApplyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((ProcessModel process, long affinity) => + AffinityApplyResult.Succeeded(affinity, affinity)); + return affinityApplyService; + } + private sealed class FakeProcessMonitorService : IProcessMonitorService { public event EventHandler? ProcessStarted; diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index 6e4825e..b4f1052 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -4,6 +4,7 @@ namespace ThreadPilot.Core.Tests { using System.Collections.Concurrent; + using System.ComponentModel; using System.Diagnostics; using System.Text.Json; using ThreadPilot.Models; @@ -67,6 +68,48 @@ public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing() } } + [Fact] + public void IsPassiveProcessAccessException_ReturnsTrue_ForModuleEnumerationFailure() + { + var exception = new Win32Exception(299, "Unable to enumerate the process modules."); + + var result = ProcessService.IsPassiveProcessAccessException(exception); + + Assert.True(result); + } + + [Fact] + public void IsPassiveProcessAccessException_ReturnsTrue_ForUnauthorizedAccess() + { + var exception = new UnauthorizedAccessException("Access denied."); + + var result = ProcessService.IsPassiveProcessAccessException(exception); + + Assert.True(result); + } + + [Theory] + [InlineData("Unable to access modules for this process.")] + [InlineData("ReadProcessMemory failed for protected process.")] + public void IsPassiveProcessAccessException_ReturnsTrue_ForKnownPassiveMessages(string message) + { + var exception = new InvalidOperationException(message); + + var result = ProcessService.IsPassiveProcessAccessException(exception); + + Assert.True(result); + } + + [Fact] + public void IsPassiveProcessAccessException_ReturnsFalse_ForUnrelatedException() + { + var exception = new InvalidOperationException("Unexpected parse failure."); + + var result = ProcessService.IsPassiveProcessAccessException(exception); + + Assert.False(result); + } + [Fact] public void TrackPriorityChange_PreservesOriginalPriority() { diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs new file mode 100644 index 0000000..513ee29 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs @@ -0,0 +1,104 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class ProcessViewModelAffinityTests + { + [Fact] + public async Task SelectingCoreMask_DoesNotApplyProcessorAffinity() + { + var processService = new Mock(MockBehavior.Loose); + var gameModeService = new Mock(MockBehavior.Loose); + gameModeService + .Setup(service => service.DisableGameModeForAffinityAsync()) + .ReturnsAsync(false); + var viewModel = CreateViewModel(processService.Object, gameModeService.Object); + + viewModel.SelectedProcess = new ProcessModel + { + ProcessId = 1234, + Name = "Game", + ProcessorAffinity = 3, + }; + + viewModel.SelectedCoreMask = CoreMask.FromProcessorAffinity(1, 2, "First Core"); + + await Task.Delay(100); + + processService.Verify( + service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SelectingCoreMask_ReportsPendingAffinityWithoutChangingCurrentAffinity() + { + var processService = new Mock(MockBehavior.Loose); + var gameModeService = new Mock(MockBehavior.Loose); + var viewModel = CreateViewModel(processService.Object, gameModeService.Object); + viewModel.CpuTopology = CreateTwoCoreTopology(); + viewModel.CpuCores = new System.Collections.ObjectModel.ObservableCollection( + viewModel.CpuTopology.LogicalCores); + + viewModel.SelectedProcess = new ProcessModel + { + ProcessId = 1234, + Name = "Game", + ProcessorAffinity = 3, + }; + + viewModel.SelectedCoreMask = CoreMask.FromProcessorAffinity(1, 2, "First Core"); + + await Task.Delay(100); + + Assert.True(viewModel.HasPendingAffinityEdits); + Assert.Equal("Current OS affinity: 0x3", viewModel.CurrentAffinityText); + Assert.Equal("Pending core mask: 0x1", viewModel.PendingAffinityText); + Assert.Equal("Core mask staged. Use Apply Affinity to change Windows affinity.", viewModel.AffinityEditStateText); + } + + private static ProcessViewModel CreateViewModel(IProcessService processService, IGameModeService gameModeService) + { + var virtualizedProcessService = new Mock(MockBehavior.Loose); + virtualizedProcessService.SetupProperty( + service => service.Configuration, + new VirtualizedProcessConfig()); + + var cpuTopologyService = new Mock(MockBehavior.Loose); + var powerPlanService = new Mock(MockBehavior.Loose); + var notificationService = new Mock(MockBehavior.Loose); + var systemTrayService = new Mock(MockBehavior.Loose); + var coreMaskService = new Mock(MockBehavior.Loose); + var associationService = new Mock(MockBehavior.Loose); + + return new ProcessViewModel( + NullLogger.Instance, + processService, + new ProcessFilterService(), + virtualizedProcessService.Object, + cpuTopologyService.Object, + powerPlanService.Object, + notificationService.Object, + systemTrayService.Object, + coreMaskService.Object, + associationService.Object, + gameModeService); + } + + private static CpuTopologyModel CreateTwoCoreTopology() + { + return new CpuTopologyModel + { + LogicalCores = + [ + new CpuCoreModel { LogicalCoreId = 0, PhysicalCoreId = 0, Label = "CPU 0" }, + new CpuCoreModel { LogicalCoreId = 1, PhysicalCoreId = 1, Label = "CPU 1" }, + ], + }; + } + } +} diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index d7ea985..06e8050 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -37,7 +37,7 @@ public partial class MainWindowViewModel : BaseViewModel private bool isProcessMonitoringActive = false; [ObservableProperty] - private string processMonitoringStatusText = "Process Monitoring: Inactive"; + private string processMonitoringStatusText = "Automation Monitoring: Inactive"; [ObservableProperty] private bool isRunningAsAdministrator = false; @@ -109,14 +109,14 @@ await this.ExecuteAsync( if (this.IsProcessMonitoringActive) { await this.processMonitorManagerService.StopAsync(); - await this.LogUserActionAsync("ProcessMonitoring", "Stopped process monitoring", "User action"); + await this.LogUserActionAsync("ProcessMonitoring", "Stopped automation monitoring", "User action"); } else { await this.processMonitorManagerService.StartAsync(); - await this.LogUserActionAsync("ProcessMonitoring", "Started process monitoring", "User action"); + await this.LogUserActionAsync("ProcessMonitoring", "Started automation monitoring", "User action"); } - }, this.IsProcessMonitoringActive ? "Stopping monitoring..." : "Starting monitoring..."); + }, this.IsProcessMonitoringActive ? "Stopping automation monitoring..." : "Starting automation monitoring..."); } [RelayCommand] @@ -151,8 +151,8 @@ private async Task UpdateStatusAsync() { this.IsProcessMonitoringActive = this.processMonitorManagerService.IsRunning; this.ProcessMonitoringStatusText = this.IsProcessMonitoringActive - ? "Process Monitoring: Active" - : "Process Monitoring: Inactive"; + ? "Automation Monitoring: Active" + : "Automation Monitoring: Inactive"; } // Update elevation status @@ -185,7 +185,7 @@ private void OnServiceStatusChanged(object? sender, ServiceStatusEventArgs e) System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.IsProcessMonitoringActive = e.IsRunning; - this.ProcessMonitoringStatusText = $"Process Monitoring: {e.Status}"; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {e.Status}"; }); } @@ -194,14 +194,14 @@ public void UpdateProcessMonitoringStatus(bool isActive, string status) if (System.Windows.Application.Current.Dispatcher.CheckAccess()) { this.IsProcessMonitoringActive = isActive; - this.ProcessMonitoringStatusText = $"Process Monitoring: {status}"; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; } else { System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.IsProcessMonitoringActive = isActive; - this.ProcessMonitoringStatusText = $"Process Monitoring: {status}"; + this.ProcessMonitoringStatusText = $"Automation Monitoring: {status}"; }); } } diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs index 366bd35..768196f 100644 --- a/ViewModels/PerformanceViewModel.cs +++ b/ViewModels/PerformanceViewModel.cs @@ -58,7 +58,7 @@ public partial class PerformanceViewModel : BaseViewModel private bool isMonitoring; [ObservableProperty] - private string monitoringStatusText = "Monitoring Stopped"; + private string monitoringStatusText = "Live metrics stopped"; [ObservableProperty] private DateTime lastUpdateTime; @@ -224,9 +224,9 @@ private async Task StartMonitoringAsync() await this.performanceService.StartMonitoringAsync(); this.IsMonitoring = true; - this.MonitoringStatusText = "Monitoring Active"; + this.MonitoringStatusText = "Live metrics active"; this.MonitoringStateText = "Active"; - this.AddTimelineEvent("Monitoring", "Real-time monitoring started.", "Info"); + this.AddTimelineEvent("Live Metrics", "Live metrics started.", "Info"); this.SetStatus("Performance monitoring started", false); } @@ -245,9 +245,9 @@ private async Task StopMonitoringAsync() await this.performanceService.StopMonitoringAsync(); this.IsMonitoring = false; - this.MonitoringStatusText = "Monitoring Stopped"; + this.MonitoringStatusText = "Live metrics stopped"; this.MonitoringStateText = "Stopped"; - this.AddTimelineEvent("Monitoring", "Real-time monitoring stopped.", "Warning"); + this.AddTimelineEvent("Live Metrics", "Live metrics stopped.", "Warning"); this.SetStatus("Performance monitoring stopped", false); } @@ -271,9 +271,9 @@ public async Task SuspendBackgroundMonitoringAsync() await this.performanceService.StopMonitoringAsync(); this.IsMonitoring = false; - this.MonitoringStatusText = "Monitoring Paused"; + this.MonitoringStatusText = "Live metrics paused"; this.MonitoringStateText = "Paused"; - this.AddTimelineEvent("Monitoring", "Monitoring paused while app is minimized.", "Info"); + this.AddTimelineEvent("Live Metrics", "Live metrics paused while app is minimized.", "Info"); } catch (Exception ex) { @@ -293,9 +293,9 @@ public async Task ResumeBackgroundMonitoringAsync() await this.performanceService.StartMonitoringAsync(); this.IsMonitoring = true; - this.MonitoringStatusText = "Monitoring Active"; + this.MonitoringStatusText = "Live metrics active"; this.MonitoringStateText = "Active"; - this.AddTimelineEvent("Monitoring", "Monitoring resumed after restore.", "Info"); + this.AddTimelineEvent("Live Metrics", "Live metrics resumed after restore.", "Info"); this.monitoringWasActiveBeforeSuspend = false; } catch (Exception ex) diff --git a/ViewModels/ProcessPowerPlanAssociationViewModel.cs b/ViewModels/ProcessPowerPlanAssociationViewModel.cs index fc22206..3346e08 100644 --- a/ViewModels/ProcessPowerPlanAssociationViewModel.cs +++ b/ViewModels/ProcessPowerPlanAssociationViewModel.cs @@ -371,12 +371,12 @@ public async Task StartMonitoringAsync() { try { - this.SetStatus("Starting monitoring service..."); + this.SetStatus("Starting automation monitoring..."); await this.monitorManagerService.StartAsync(); } catch (Exception ex) { - this.SetStatus($"Error starting monitoring: {ex.Message}", false); + this.SetStatus($"Error starting automation monitoring: {ex.Message}", false); } } @@ -385,12 +385,12 @@ public async Task StopMonitoringAsync() { try { - this.SetStatus("Stopping monitoring service..."); + this.SetStatus("Stopping automation monitoring..."); await this.monitorManagerService.StopAsync(); } catch (Exception ex) { - this.SetStatus($"Error stopping monitoring: {ex.Message}", false); + this.SetStatus($"Error stopping automation monitoring: {ex.Message}", false); } } diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 7a41296..7fd87c4 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -156,21 +156,22 @@ partial void OnSelectedProcessChanged(ProcessModel? value) { if (value != null && CpuTopology != null) { - hasPendingAffinityEdits = false; + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); // Immediately fetch and display real-time process information TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex => { - Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name); + this.Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name); }); } else if (value == null) { // Clear selection - ClearProcessSelection(); + this.ClearProcessSelection(); } // Update system tray context menu - systemTrayService.UpdateContextMenu(value?.Name, value != null); + this.systemTrayService.UpdateContextMenu(value?.Name, value != null); } private async Task HandleSelectedProcessChangedAsync(ProcessModel value) @@ -197,6 +198,7 @@ private async Task HandleSelectedProcessChangedAsync(ProcessModel value) System.Windows.Application.Current.Dispatcher.Invoke(() => { this.UpdateCoreSelections(value.ProcessorAffinity); + this.UpdateAffinityDisplayState(); value.ForceNotifyProcessorAffinityChanged(); // Update priority display - trigger property change to refresh ComboBox @@ -254,7 +256,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.QuickApplyAffinityAndPowerPlanCommand.ExecuteAsync(null); this.systemTrayService.ShowBalloonTip( "ThreadPilot", - $"Settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000); + $"Pending settings applied to {this.SelectedProcess?.Name ?? "selected process"}", 2000); }); } catch (Exception ex) @@ -264,7 +266,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.systemTrayService.ShowBalloonTip( "ThreadPilot Error", - $"Failed to apply settings: {ex.Message}", 3000); + $"Failed to apply pending settings: {ex.Message}", 3000); }); } } @@ -324,61 +326,6 @@ private void OnCorePropertyChanged(object? sender, PropertyChangedEventArgs e) this.Logger.LogDebug("Core property changed but cores are read-only - no action taken"); } - private async Task AutoApplyAffinityAsync() - { - if (this.SelectedProcess == null || !this.hasPendingAffinityEdits) - { - return; - } - - try - { - var affinityMask = this.CalculateAffinityMask(); - if (affinityMask == 0) - { - this.Logger.LogDebug("Affinity mask is zero, skipping auto-apply"); - return; - } - - this.Logger.LogInformation( - "Auto-applying affinity 0x{AffinityMask:X} to process {ProcessName} (PID: {ProcessId})", - affinityMask, this.SelectedProcess.Name, this.SelectedProcess.ProcessId); - - // Apply the affinity change - await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask); - - // Immediately refresh the process to get the actual system state - await this.processService.RefreshProcessInfo(this.SelectedProcess); - - // Update UI to reflect the actual system affinity - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); - - // Notify UI of all changes - this.OnPropertyChanged(nameof(this.SelectedProcess)); - - // Clear pending edits flag - this.hasPendingAffinityEdits = false; - - this.Logger.LogInformation("Auto-applied affinity successfully to {ProcessName}", this.SelectedProcess.Name); - } - catch (Exception ex) - { - this.Logger.LogWarning(ex, "Failed to auto-apply affinity to {ProcessName}", this.SelectedProcess.Name); - - // Try to refresh process info even if setting failed, to show current state - try - { - await this.processService.RefreshProcessInfo(this.SelectedProcess); - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); - this.OnPropertyChanged(nameof(this.SelectedProcess)); - } - catch - { - // Process may have terminated - } - } - } - private void UpdateHyperThreadingStatus() { if (this.CpuTopology == null) @@ -432,7 +379,7 @@ private void UpdateCoreSelections(long affinityMask, bool forceSync = false) return; } - if (this.hasPendingAffinityEdits && !forceSync) + if (this.HasPendingAffinityEdits && !forceSync) { this.Logger.LogDebug("Skipping affinity sync because user edits are pending"); return; @@ -492,8 +439,10 @@ private void UpdateCoreSelections(long affinityMask, bool forceSync = false) if (forceSync) { - this.hasPendingAffinityEdits = false; + this.HasPendingAffinityEdits = false; } + + this.UpdateAffinityDisplayState(); } private long CalculateAffinityMask() @@ -511,6 +460,34 @@ private long CalculateAffinityMask() return selectedCores.Aggregate(0L, (mask, core) => mask | core.AffinityMask); } + private void UpdateAffinityDisplayState() + { + var currentMask = this.SelectedProcess?.ProcessorAffinity; + this.CurrentAffinityText = currentMask.HasValue + ? $"Current OS affinity: 0x{currentMask.Value:X}" + : "Current OS affinity: no process selected"; + + if (this.SelectedProcess == null) + { + this.PendingAffinityText = "Pending core mask: none"; + this.AffinityEditStateText = "Select a process to view its current Windows affinity."; + return; + } + + if (!this.HasPendingAffinityEdits) + { + this.PendingAffinityText = "Pending core mask: none"; + this.AffinityEditStateText = "Current OS affinity is displayed. Select a core mask to stage a change."; + return; + } + + var pendingMask = this.CalculateAffinityMask(); + this.PendingAffinityText = pendingMask > 0 + ? $"Pending core mask: 0x{pendingMask:X}" + : "Pending core mask: no cores selected"; + this.AffinityEditStateText = "Core mask staged. Use Apply Affinity to change Windows affinity."; + } + [RelayCommand] public async Task LoadMoreProcesses() { @@ -669,67 +646,32 @@ private async Task RefreshProcesses() try { - // Store the currently selected process ID to preserve selection var selectedProcessId = this.SelectedProcess?.ProcessId; var currentProcesses = this.ShowActiveApplicationsOnly ? await this.processService.GetActiveApplicationsAsync() : await this.processService.GetProcessesAsync(); - // Update UI on the UI thread await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - // Update existing processes or add new ones - foreach (var process in currentProcesses) - { - var existingProcess = this.Processes.FirstOrDefault(p => p.ProcessId == process.ProcessId); - if (existingProcess != null) - { - // Update existing process data by copying properties - existingProcess.MemoryUsage = process.MemoryUsage; - existingProcess.Priority = process.Priority; - existingProcess.ProcessorAffinity = process.ProcessorAffinity; - existingProcess.MainWindowHandle = process.MainWindowHandle; - existingProcess.MainWindowTitle = process.MainWindowTitle; - existingProcess.HasVisibleWindow = process.HasVisibleWindow; - existingProcess.CpuUsage = process.CpuUsage; - } - else - { - this.Processes.Add(process); - } - } - - // Remove terminated processes - var terminatedProcesses = this.Processes - .Where(p => !currentProcesses.Any(cp => cp.ProcessId == p.ProcessId)) - .ToList(); - - // Check if selected process was terminated - bool selectedProcessTerminated = false; - foreach (var terminated in terminatedProcesses) - { - if (terminated.ProcessId == selectedProcessId) - { - selectedProcessTerminated = true; - } - this.Processes.Remove(terminated); - } + var deltaResult = ProcessListDeltaUpdater.ApplyDelta( + this.Processes, + currentProcesses, + selectedProcessId); this.FilterProcesses(); - // Restore selection if the process still exists - if (selectedProcessId.HasValue && !selectedProcessTerminated) + if (deltaResult.SelectedProcess != null) { - var processToSelect = this.FilteredProcesses.FirstOrDefault(p => p.ProcessId == selectedProcessId.Value); + var processToSelect = this.FilteredProcesses.FirstOrDefault( + p => p.ProcessId == deltaResult.SelectedProcess.ProcessId); if (processToSelect != null) { this.SelectedProcess = processToSelect; } } - else if (selectedProcessTerminated) + else if (deltaResult.SelectedProcessTerminated) { - // Clear selection and reset UI if selected process was terminated this.SelectedProcess = null; this.ClearProcessSelection(); } @@ -747,7 +689,8 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => [RelayCommand] private async Task SetAffinity() { - if (this.SelectedProcess == null) + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) { return; } @@ -755,65 +698,57 @@ private async Task SetAffinity() try { var affinityMask = this.CalculateAffinityMask(); - if (affinityMask == 0) - { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.SetStatus("Please select at least one CPU core", false); - }); - return; - } await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.SetStatus($"Setting affinity for {this.SelectedProcess.Name}..."); + this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); }); - // Apply the affinity change - await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask); - - // Immediately refresh the process to get the actual system state - await this.processService.RefreshProcessInfo(this.SelectedProcess); + var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask); - // Update UI to reflect the actual system affinity (not our calculated one) - // This ensures we show what the OS actually set, which may differ from our request await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); - - // Notify UI of all changes + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + selectedProcess.ForceNotifyProcessorAffinityChanged(); this.OnPropertyChanged(nameof(this.SelectedProcess)); - // Verify the affinity was set correctly - if (this.SelectedProcess.ProcessorAffinity == affinityMask) + if (result.Success) + { + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + this.SetStatus($"Affinity applied successfully to {selectedProcess.Name} (0x{result.VerifiedMask:X}).", false); + _ = this.notificationService.ShowNotificationAsync("Affinity applied", $"{selectedProcess.Name}: 0x{result.VerifiedMask:X}", NotificationType.Success); + } + else if (result.FailureReason == AffinityApplyFailureReason.VerificationMismatch) + { + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + this.SetStatus(result.Message, false); + _ = this.notificationService.ShowNotificationAsync("Affinity adjusted", result.Message, NotificationType.Warning); + } + else if (result.FailureReason == AffinityApplyFailureReason.ProcessTerminated) { - this.SetStatus($"Affinity applied successfully to {this.SelectedProcess.Name} (0x{affinityMask:X}).", false); - _ = this.notificationService.ShowNotificationAsync("Affinity applied", $"{this.SelectedProcess.Name}: 0x{affinityMask:X}", NotificationType.Success); + this.SelectedProcess = null; + this.ClearProcessSelection(); + this.SetStatus(result.Message, false); + _ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning); + } + else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied) + { + this.SetStatus(result.Message, false); + _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); } else { - this.SetStatus($"Affinity adjusted by system for {this.SelectedProcess.Name} to 0x{this.SelectedProcess.ProcessorAffinity:X}.", false); - _ = this.notificationService.ShowNotificationAsync("Affinity adjusted", $"{this.SelectedProcess.Name}: 0x{this.SelectedProcess.ProcessorAffinity:X}", NotificationType.Warning); + this.SetStatus(result.Message, false); + _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error); } }); } catch (Exception ex) { var friendly = ex.Message; - if (friendly.Contains("access denied", StringComparison.OrdinalIgnoreCase) || - friendly.Contains("anti-cheat", StringComparison.OrdinalIgnoreCase)) - { - friendly = "Affinity change blocked (anti-cheat or insufficient privileges)."; - _ = this.notificationService.ShowNotificationAsync("Affinity blocked", friendly, NotificationType.Warning); - } - else if (friendly.Contains("invalid affinity", StringComparison.OrdinalIgnoreCase)) - { - _ = this.notificationService.ShowNotificationAsync("Affinity invalid", friendly, NotificationType.Error); - } - else - { - _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); - } + _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { @@ -823,11 +758,18 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => // Try to refresh process info even if setting failed, to show current state try { - await this.processService.RefreshProcessInfo(this.SelectedProcess); + if (this.SelectedProcess != null) + { + await this.processService.RefreshProcessInfo(this.SelectedProcess); + } + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); - this.OnPropertyChanged(nameof(this.SelectedProcess)); + if (this.SelectedProcess != null) + { + this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + } }); } catch @@ -879,11 +821,9 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.suppressCoreSelectionEvents = false; } - // Trigger auto-apply with the preset mask - this.hasPendingAffinityEdits = true; - - // Apply immediately for presets (no debounce needed) - _ = this.AutoApplyAffinityAsync(); + // Keep the preset as a pending selection; affinity changes require an explicit apply command. + this.HasPendingAffinityEdits = true; + this.UpdateAffinityDisplayState(); }); } @@ -941,7 +881,8 @@ partial void OnSelectedCoreMaskChanged(CoreMask? oldValue, CoreMask? newValue) private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) { - if (this.SelectedProcess == null || mask == null) + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null || mask == null) { return; } @@ -951,7 +892,7 @@ private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) { this.Logger.LogInformation( "Applying mask '{MaskName}' to process {ProcessName} (PID: {ProcessId})", - mask.Name, this.SelectedProcess.Name, this.SelectedProcess.ProcessId); + mask.Name, selectedProcess.Name, selectedProcess.ProcessId); // Convert mask to affinity long affinity = mask.ToProcessorAffinity(); @@ -967,34 +908,35 @@ private async Task ApplyCoreMaskToProcessAsync(CoreMask mask) // Game Mode can interfere with CPU Sets, particularly on AMD systems await this.gameModeService.DisableGameModeForAffinityAsync(); - // Apply affinity using ProcessService (which uses CPU Sets with fallback) - await this.processService.SetProcessorAffinity(this.SelectedProcess, affinity); - - // Refresh process info to get actual system state - await this.processService.RefreshProcessInfo(this.SelectedProcess); + var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinity); - // CRITICAL: Force UI updates on UI thread - // RefreshProcessInfo runs on background thread, DataGrid won't receive PropertyChanged from non-UI threads System.Windows.Application.Current.Dispatcher.Invoke(() => { - // Force PropertyChanged notification for ProcessorAffinity property - // This ensures the DataGrid Affinity column binding updates immediately - this.SelectedProcess.ForceNotifyProcessorAffinityChanged(); - - // Update Advanced CPU Affinity checkboxes to reflect the mask - this.UpdateCoreSelectionsFromMask(mask); - - // Force complete refresh of SelectedProcess bindings in DataGrid + selectedProcess.ForceNotifyProcessorAffinityChanged(); + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); this.OnPropertyChanged(nameof(this.SelectedProcess)); }); - this.SetStatus($"Applied mask '{mask.Name}' to {this.SelectedProcess.Name}"); - this.Logger.LogInformation("Successfully applied mask '{MaskName}' to {ProcessName}", mask.Name, this.SelectedProcess.Name); + if (!result.Success) + { + this.SetStatus(result.Message); + this.Logger.LogWarning( + "Failed to apply mask '{MaskName}' to process {ProcessName}: {Message}", + mask.Name, + selectedProcess.Name, + result.Message); + return; + } + + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + this.SetStatus($"Applied mask '{mask.Name}' to {selectedProcess.Name}"); + this.Logger.LogInformation("Successfully applied mask '{MaskName}' to {ProcessName}", mask.Name, selectedProcess.Name); } catch (Exception ex) { this.Logger.LogError(ex, "Failed to apply mask '{MaskName}' to process {ProcessName}", - mask.Name, this.SelectedProcess.Name); + mask.Name, selectedProcess.Name); this.SetStatus($"Error applying mask: {ex.Message}"); } finally @@ -1020,6 +962,8 @@ private void UpdateCoreSelectionsFromMask(CoreMask mask) } this.OnPropertyChanged(nameof(this.CpuCores)); + this.HasPendingAffinityEdits = this.SelectedProcess != null; + this.UpdateAffinityDisplayState(); } finally { @@ -1031,7 +975,8 @@ private void UpdateCoreSelectionsFromMask(CoreMask mask) [RelayCommand] private async Task QuickApplyAffinityAndPowerPlan() { - if (this.SelectedProcess == null) + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) { return; } @@ -1040,14 +985,28 @@ private async Task QuickApplyAffinityAndPowerPlan() { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.SetStatus($"Applying settings to {this.SelectedProcess.Name}..."); + this.SetStatus($"Applying pending settings to {selectedProcess.Name}..."); }); // Apply CPU affinity var affinityMask = this.CalculateAffinityMask(); if (affinityMask > 0) { - await this.processService.SetProcessorAffinity(this.SelectedProcess, affinityMask); + var result = await this.affinityApplyService.ApplyAsync(selectedProcess, affinityMask); + if (!result.Success) + { + await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + { + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + selectedProcess.ForceNotifyProcessorAffinityChanged(); + this.OnPropertyChanged(nameof(this.SelectedProcess)); + this.SetStatus(result.Message, false); + }); + return; + } + + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); } // Apply power plan if selected @@ -1056,24 +1015,25 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); } - await this.processService.RefreshProcessInfo(this.SelectedProcess); + await this.processService.RefreshProcessInfo(selectedProcess); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.UpdateCoreSelections(this.SelectedProcess.ProcessorAffinity, true); + this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); + selectedProcess.ForceNotifyProcessorAffinityChanged(); this.OnPropertyChanged(nameof(this.SelectedProcess)); }); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.SetStatus($"Quick apply completed for {this.SelectedProcess.Name}.", false); + this.SetStatus($"Pending settings applied to {selectedProcess.Name}.", false); }); } catch (Exception ex) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.SetStatus($"Error applying settings: {ex.Message}", false); + this.SetStatus($"Error applying pending settings: {ex.Message}", false); }); } } @@ -1329,6 +1289,14 @@ public void ResumeRefresh() this.SetUiRefreshEnabled(true, refreshImmediately: true); } + public void ApplyRefreshDecision(AppRefreshDecision decision) + { + ArgumentNullException.ThrowIfNull(decision); + + this.virtualizedProcessService.Configuration.EnableBackgroundLoading = decision.VirtualizedPreloadEnabled; + this.SetUiRefreshEnabled(decision.ProcessUiRefreshEnabled, decision.ImmediateProcessRefresh); + } + public void SetProcessViewActive(bool isActive) { if (this.isProcessViewActive == isActive) @@ -1515,6 +1483,9 @@ private void ClearProcessSelection() core.IsSelected = false; } + this.HasPendingAffinityEdits = false; + this.UpdateAffinityDisplayState(); + // Reset power plan to current system default _ = Task.Run(async () => { diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 58e9f5d..6e0d754 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -26,6 +26,7 @@ namespace ThreadPilot.ViewModels using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; using ThreadPilot.Models; using ThreadPilot.Services; @@ -43,6 +44,7 @@ public partial class ProcessViewModel : BaseViewModel private readonly ICoreMaskService coreMaskService; private readonly IProcessPowerPlanAssociationService associationService; private readonly IGameModeService gameModeService; + private readonly IAffinityApplyService affinityApplyService; private System.Timers.Timer? refreshTimer; private bool isUiRefreshPaused; private bool isProcessViewActive = true; @@ -51,7 +53,6 @@ public partial class ProcessViewModel : BaseViewModel private bool isApplyingFilter; private bool filterRefreshPending; private bool suppressCoreSelectionEvents; - private bool hasPendingAffinityEdits; [ObservableProperty] private ObservableCollection processes = new(); @@ -85,6 +86,18 @@ public partial class ProcessViewModel : BaseViewModel [ObservableProperty] private CoreMask? selectedCoreMask; + [ObservableProperty] + private bool hasPendingAffinityEdits; + + [ObservableProperty] + private string currentAffinityText = "Current OS affinity: no process selected"; + + [ObservableProperty] + private string pendingAffinityText = "Pending core mask: none"; + + [ObservableProperty] + private string affinityEditStateText = "Select a process to view its current Windows affinity."; + [ObservableProperty] private bool isTopologyDetectionSuccessful = false; @@ -163,6 +176,7 @@ public ProcessViewModel( ICoreMaskService coreMaskService, IProcessPowerPlanAssociationService associationService, IGameModeService gameModeService, + IAffinityApplyService? affinityApplyService = null, IEnhancedLoggingService? enhancedLoggingService = null) : base(logger, enhancedLoggingService) { @@ -176,6 +190,10 @@ public ProcessViewModel( this.coreMaskService = coreMaskService ?? throw new ArgumentNullException(nameof(coreMaskService)); this.associationService = associationService ?? throw new ArgumentNullException(nameof(associationService)); this.gameModeService = gameModeService ?? throw new ArgumentNullException(nameof(gameModeService)); + this.affinityApplyService = affinityApplyService ?? new AffinityApplyService( + this.processService, + this.cpuTopologyService, + NullLogger.Instance); // Subscribe to topology detection events this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; diff --git a/Views/LogViewerView.xaml b/Views/LogViewerView.xaml index fefad62..77a58ca 100644 --- a/Views/LogViewerView.xaml +++ b/Views/LogViewerView.xaml @@ -140,19 +140,20 @@ - - - - + + + + + @@ -215,18 +216,46 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + - + - - - - - - - + + + + + + + + + + + + - - - - + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - -