diff --git a/Models/CoreMask.cs b/Models/CoreMask.cs index 81fc693..5d3f0ef 100644 --- a/Models/CoreMask.cs +++ b/Models/CoreMask.cs @@ -42,6 +42,12 @@ public partial class CoreMask : ObservableObject /// public ObservableCollection BoolMask { get; set; } = new(); + public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; + + public CpuSelection? CpuSelection { get; set; } + + public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } + [ObservableProperty] private bool isDefault = false; @@ -135,6 +141,9 @@ public CoreMask Clone() Description = this.Description, IsEnabled = this.IsEnabled, IsDefault = false, + ProfileSchemaVersion = this.ProfileSchemaVersion, + CpuSelection = this.CpuSelection, + CpuSelectionMigration = this.CpuSelectionMigration, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, }; diff --git a/Models/CpuAffinityProfileSchemaVersions.cs b/Models/CpuAffinityProfileSchemaVersions.cs new file mode 100644 index 0000000..86c71bf --- /dev/null +++ b/Models/CpuAffinityProfileSchemaVersions.cs @@ -0,0 +1,25 @@ +/* + * 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.Models +{ + public static class CpuAffinityProfileSchemaVersions + { + public const int Legacy = 1; + + public const int CpuSelection = 2; + } +} diff --git a/Models/CpuSelectionMigrationMetadata.cs b/Models/CpuSelectionMigrationMetadata.cs new file mode 100644 index 0000000..a3c47e3 --- /dev/null +++ b/Models/CpuSelectionMigrationMetadata.cs @@ -0,0 +1,35 @@ +/* + * 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.Models +{ + public sealed record CpuSelectionMigrationMetadata + { + public bool CreatedFromLegacyAffinityMask { get; init; } + + public bool CreatedFromLegacyCoreMask { get; init; } + + public bool ReviewRequired { get; init; } + + public string MigrationConfidence { get; init; } = string.Empty; + + public string Reason { get; init; } = string.Empty; + + public CpuTopologySignature? TopologySignature { get; init; } + + public long? SourceLegacyAffinityMask { get; init; } + } +} diff --git a/Models/ProcessProfileSnapshot.cs b/Models/ProcessProfileSnapshot.cs new file mode 100644 index 0000000..b8959f2 --- /dev/null +++ b/Models/ProcessProfileSnapshot.cs @@ -0,0 +1,35 @@ +/* + * 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.Models +{ + using System.Diagnostics; + + public sealed class ProcessProfileSnapshot + { + public int ProfileSchemaVersion { get; set; } = CpuAffinityProfileSchemaVersions.Legacy; + + public string ProcessName { get; set; } = string.Empty; + + public ProcessPriorityClass Priority { get; set; } + + public long ProcessorAffinity { get; set; } + + public CpuSelection? CpuSelection { get; set; } + + public CpuSelectionMigrationMetadata? CpuSelectionMigration { get; set; } + } +} diff --git a/Models/ProfileModel.cs b/Models/ProfileModel.cs index a886dfb..4eafee9 100644 --- a/Models/ProfileModel.cs +++ b/Models/ProfileModel.cs @@ -46,6 +46,15 @@ public partial class ProfileModel : ObservableObject, IModel [ObservableProperty] private long processorAffinity = -1; // All cores + [ObservableProperty] + private int profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy; + + [ObservableProperty] + private CpuSelection? cpuSelection = null; + + [ObservableProperty] + private CpuSelectionMigrationMetadata? cpuSelectionMigration = null; + [ObservableProperty] private string description = string.Empty; @@ -80,6 +89,9 @@ public IModel Clone() ProcessName = this.ProcessName, Priority = this.Priority, ProcessorAffinity = this.ProcessorAffinity, + ProfileSchemaVersion = this.ProfileSchemaVersion, + CpuSelection = this.CpuSelection, + CpuSelectionMigration = this.CpuSelectionMigration, Description = this.Description, IsEnabled = this.IsEnabled, createdAt = DateTime.UtcNow, diff --git a/Services/CoreMaskService.cs b/Services/CoreMaskService.cs index 49ce3ee..daa121d 100644 --- a/Services/CoreMaskService.cs +++ b/Services/CoreMaskService.cs @@ -45,6 +45,8 @@ public class CoreMaskService : ICoreMaskService private readonly ILogger logger; private readonly ICpuTopologyService cpuTopologyService; private readonly IServiceProvider serviceProvider; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; private readonly string masksFilePath; private bool initialized = false; @@ -63,11 +65,15 @@ public class CoreMaskService : ICoreMaskService public CoreMaskService( ILogger logger, ICpuTopologyService cpuTopologyService, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + ICpuTopologyProvider? cpuTopologyProvider = null, + CpuSelectionMigrationService? cpuSelectionMigrationService = null) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); StoragePaths.EnsureAppDataDirectories(); this.masksFilePath = StoragePaths.CoreMasksFilePath; @@ -184,12 +190,17 @@ public async Task SaveMasksAsync() { try { + await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); + var data = this.AvailableMasks.Select(m => new { id = m.Id, name = m.Name, description = m.Description, boolMask = m.BoolMask.ToList(), + profileSchemaVersion = m.ProfileSchemaVersion, + cpuSelection = m.CpuSelection, + cpuSelectionMigration = m.CpuSelectionMigration, isDefault = m.IsDefault, isEnabled = m.IsEnabled, createdAt = m.CreatedAt, @@ -238,6 +249,9 @@ public async Task LoadMasksAsync() Id = item.GetProperty("id").GetString() ?? Guid.NewGuid().ToString(), Name = item.GetProperty("name").GetString() ?? "Unnamed", Description = item.GetProperty("description").GetString() ?? string.Empty, + ProfileSchemaVersion = item.TryGetProperty("profileSchemaVersion", out var schemaVersion) + ? schemaVersion.GetInt32() + : CpuAffinityProfileSchemaVersions.Legacy, IsDefault = item.GetProperty("isDefault").GetBoolean(), IsEnabled = item.GetProperty("isEnabled").GetBoolean(), CreatedAt = item.GetProperty("createdAt").GetDateTime(), @@ -250,6 +264,18 @@ public async Task LoadMasksAsync() mask.BoolMask.Add(bit.GetBoolean()); } + if (item.TryGetProperty("cpuSelection", out var cpuSelectionElement) && + cpuSelectionElement.ValueKind != JsonValueKind.Null) + { + mask.CpuSelection = cpuSelectionElement.Deserialize(JsonOptions); + } + + if (item.TryGetProperty("cpuSelectionMigration", out var migrationElement) && + migrationElement.ValueKind != JsonValueKind.Null) + { + mask.CpuSelectionMigration = migrationElement.Deserialize(JsonOptions); + } + this.AvailableMasks.Add(mask); } catch (Exception ex) @@ -258,6 +284,7 @@ public async Task LoadMasksAsync() } } + await this.ApplyCpuSelectionMigrationAsync().ConfigureAwait(false); this.logger.LogInformation("Loaded {Count} masks from {Path}", this.AvailableMasks.Count, this.masksFilePath); } catch (Exception ex) @@ -280,6 +307,54 @@ public async Task IsMaskReferencedByProfilesAsync(string maskId) } } + private async Task ApplyCpuSelectionMigrationAsync() + { + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology == null) + { + return; + } + + foreach (var mask in this.AvailableMasks) + { + if (mask.CpuSelection != null) + { + mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + continue; + } + + if (mask.BoolMask.Count == 0) + { + continue; + } + + var migrated = this.cpuSelectionMigrationService.MigrateFromLegacyCoreMask( + mask.BoolMask.ToList(), + topology); + mask.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + mask.CpuSelection = migrated.Selection; + mask.CpuSelectionMigration = migrated.Metadata; + } + } + + private async Task TryGetTopologySnapshotAsync() + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to get CPU topology snapshot for core mask CpuSelection migration"); + return null; + } + } + public async Task IsMaskActivelyAppliedAsync(string maskId) { try @@ -732,4 +807,3 @@ private CoreMask CreateCoreMaskFromBoolList(string name, List boolMask, st } } } - diff --git a/Services/CpuSelectionMigrationResult.cs b/Services/CpuSelectionMigrationResult.cs new file mode 100644 index 0000000..5b25804 --- /dev/null +++ b/Services/CpuSelectionMigrationResult.cs @@ -0,0 +1,24 @@ +/* + * 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 ThreadPilot.Models; + + public sealed record CpuSelectionMigrationResult( + CpuSelection Selection, + CpuSelectionMigrationMetadata Metadata); +} diff --git a/Services/CpuSelectionMigrationService.cs b/Services/CpuSelectionMigrationService.cs new file mode 100644 index 0000000..eb6799c --- /dev/null +++ b/Services/CpuSelectionMigrationService.cs @@ -0,0 +1,168 @@ +/* + * 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 ThreadPilot.Models; + + public sealed class CpuSelectionMigrationService + { + public CpuSelectionMigrationResult MigrateFromLegacyAffinityMask( + long mask, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(topology); + + var selection = CpuSelection.FromLegacyAffinityMask(mask, topology); + var reviewRequired = topology.Signature.LogicalProcessorCount > 64 || + topology.Signature.ProcessorGroupCount > 1; + + return new CpuSelectionMigrationResult( + selection, + new CpuSelectionMigrationMetadata + { + CreatedFromLegacyAffinityMask = true, + ReviewRequired = reviewRequired, + MigrationConfidence = reviewRequired ? "Medium" : "High", + Reason = reviewRequired + ? "Migrated from a legacy affinity mask on a topology that may not be fully represented by legacy masks." + : "Migrated from a legacy affinity mask.", + TopologySignature = topology.Signature, + SourceLegacyAffinityMask = mask, + }); + } + + public CpuSelectionMigrationResult MigrateFromLegacyCoreMask( + IReadOnlyList coreMask, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(coreMask); + ArgumentNullException.ThrowIfNull(topology); + + var orderedProcessors = topology.LogicalProcessors + .OrderBy(processor => processor.GlobalIndex) + .ThenBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + var selectedProcessors = orderedProcessors + .Take(Math.Min(coreMask.Count, orderedProcessors.Count)) + .Where((_, index) => coreMask[index]) + .ToList(); + var reviewRequired = coreMask.Count != orderedProcessors.Count; + var selection = CpuSelection.FromProcessors( + selectedProcessors, + topology, + "Migrated from legacy core mask"); + + return new CpuSelectionMigrationResult( + selection, + new CpuSelectionMigrationMetadata + { + CreatedFromLegacyCoreMask = true, + ReviewRequired = reviewRequired, + MigrationConfidence = reviewRequired ? "Medium" : "High", + Reason = reviewRequired + ? "Migrated from a legacy core mask whose length differs from the current topology." + : "Migrated from a legacy core mask.", + TopologySignature = topology.Signature, + }); + } + + public long? BuildLegacyAffinityMaskIfRepresentable(CpuSelection selection) => + CpuSelection.ToLegacyAffinityMaskOrNull(selection); + + public bool ShouldRequireReview( + CpuSelection selection, + CpuTopologySignature? savedSignature, + CpuTopologySnapshot currentTopology) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(currentTopology); + + if (savedSignature == null || savedSignature != currentTopology.Signature) + { + return true; + } + + var currentProcessors = currentTopology.LogicalProcessors.ToHashSet(); + return selection.LogicalProcessors.Any(processor => !currentProcessors.Contains(processor)); + } + + public ProcessProfileSnapshot MigrateProcessProfile( + ProcessProfileSnapshot profile, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(topology); + + if (profile.CpuSelection != null) + { + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata + { + ReviewRequired = this.ShouldRequireReview( + profile.CpuSelection, + profile.CpuSelection.Metadata.TopologySignature, + topology), + MigrationConfidence = "High", + Reason = "Profile already contains a CpuSelection.", + TopologySignature = profile.CpuSelection.Metadata.TopologySignature, + SourceLegacyAffinityMask = profile.ProcessorAffinity, + }; + return profile; + } + + var migrated = this.MigrateFromLegacyAffinityMask(profile.ProcessorAffinity, topology); + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + profile.CpuSelection = migrated.Selection; + profile.CpuSelectionMigration = migrated.Metadata; + return profile; + } + + public ProcessProfileSnapshot PrepareProcessProfileForSave( + ProcessProfileSnapshot profile, + CpuTopologySnapshot topology) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(topology); + + if (profile.CpuSelection == null) + { + this.MigrateProcessProfile(profile, topology); + } + + profile.ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection; + if (profile.CpuSelection != null) + { + var legacyMask = this.BuildLegacyAffinityMaskIfRepresentable(profile.CpuSelection); + if (legacyMask.HasValue) + { + profile.ProcessorAffinity = legacyMask.Value; + } + } + + profile.CpuSelectionMigration ??= new CpuSelectionMigrationMetadata + { + MigrationConfidence = "High", + Reason = "Saved with CpuSelection profile schema.", + TopologySignature = topology.Signature, + SourceLegacyAffinityMask = profile.ProcessorAffinity, + }; + + return profile; + } + } +} diff --git a/Services/ProcessService.cs b/Services/ProcessService.cs index 0f69d70..c6c14a4 100644 --- a/Services/ProcessService.cs +++ b/Services/ProcessService.cs @@ -43,6 +43,11 @@ public class ProcessService : IProcessService private readonly IPassiveProcessErrorThrottle passiveProcessErrorThrottle; private readonly Func profilesDirectoryProvider; private readonly CpuSelectionAffinityApplier cpuSelectionAffinityApplier; + private readonly ICpuTopologyProvider? cpuTopologyProvider; + private readonly CpuSelectionMigrationService cpuSelectionMigrationService; + private readonly Func? loadProcessProfilePrioritySetter; + private readonly Func>? loadProcessProfileCpuSelectionSetter; + private readonly Func? loadProcessProfileLegacyAffinitySetter; private string ProfilesDirectory => this.profilesDirectoryProvider(); @@ -58,7 +63,36 @@ public ProcessService( Func? profilesDirectoryProvider = null, IForegroundProcessService? foregroundProcessService = null, IProcessClassifier? processClassifier = null, - IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null) + IPassiveProcessErrorThrottle? passiveProcessErrorThrottle = null, + ICpuTopologyProvider? cpuTopologyProvider = null, + CpuSelectionMigrationService? cpuSelectionMigrationService = null) + : this( + logger, + securityService, + profilesDirectoryProvider, + foregroundProcessService, + processClassifier, + passiveProcessErrorThrottle, + cpuTopologyProvider, + cpuSelectionMigrationService, + loadProcessProfilePrioritySetter: null, + loadProcessProfileCpuSelectionSetter: null, + loadProcessProfileLegacyAffinitySetter: null) + { + } + + internal ProcessService( + ILogger? logger, + ISecurityService? securityService, + Func? profilesDirectoryProvider, + IForegroundProcessService? foregroundProcessService, + IProcessClassifier? processClassifier, + IPassiveProcessErrorThrottle? passiveProcessErrorThrottle, + ICpuTopologyProvider? cpuTopologyProvider, + CpuSelectionMigrationService? cpuSelectionMigrationService, + Func? loadProcessProfilePrioritySetter, + Func>? loadProcessProfileCpuSelectionSetter, + Func? loadProcessProfileLegacyAffinitySetter) { this.logger = logger; this.securityService = securityService; @@ -66,6 +100,11 @@ public ProcessService( this.processClassifier = processClassifier ?? new ProcessClassifier(new ProcessFilterService()); this.passiveProcessErrorThrottle = passiveProcessErrorThrottle ?? new PassiveProcessErrorThrottle(); this.profilesDirectoryProvider = profilesDirectoryProvider ?? (() => StoragePaths.ProfilesDirectory); + this.cpuTopologyProvider = cpuTopologyProvider; + this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); + this.loadProcessProfilePrioritySetter = loadProcessProfilePrioritySetter; + this.loadProcessProfileCpuSelectionSetter = loadProcessProfileCpuSelectionSetter; + this.loadProcessProfileLegacyAffinitySetter = loadProcessProfileLegacyAffinitySetter; this.cpuSelectionAffinityApplier = new CpuSelectionAffinityApplier( this.GetOrCreateCpuSetHandler, this.ApplyLegacyProcessorAffinityDirectAsync, @@ -111,15 +150,6 @@ public CpuSample(TimeSpan totalProcessorTime, DateTime timestamp) public DateTime Timestamp { get; set; } } - private sealed class ProcessProfileSnapshot - { - public string ProcessName { get; set; } = string.Empty; - - public ProcessPriorityClass Priority { get; set; } - - public long ProcessorAffinity { get; set; } - } - private double CalculateCpuUsage(Process process) { try @@ -588,13 +618,19 @@ await Task.Run(() => public async Task SaveProcessProfile(string profileName, ProcessModel process) { - var profile = new + var profile = new ProcessProfileSnapshot { ProcessName = process.Name, Priority = process.Priority, ProcessorAffinity = process.ProcessorAffinity, }; + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology != null) + { + this.cpuSelectionMigrationService.PrepareProcessProfileForSave(profile, topology); + } + var filePath = Path.Combine(this.ProfilesDirectory, $"{profileName}.json"); var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); await AtomicFileWriter.WriteAllTextAsync(filePath, json, Encoding.UTF8).ConfigureAwait(false); @@ -622,11 +658,70 @@ public async Task LoadProcessProfile(string profileName, ProcessModel proc return false; } - await this.SetProcessPriority(process, profile.Priority).ConfigureAwait(false); - await this.SetProcessorAffinity(process, profile.ProcessorAffinity).ConfigureAwait(false); + await this.SetLoadProcessProfilePriorityAsync(process, profile.Priority).ConfigureAwait(false); + var topology = await this.TryGetTopologySnapshotAsync().ConfigureAwait(false); + if (topology != null) + { + this.cpuSelectionMigrationService.MigrateProcessProfile(profile, topology); + } + + if (profile.CpuSelection != null) + { + var result = await this.SetLoadProcessProfileCpuSelectionAsync(process, profile.CpuSelection).ConfigureAwait(false); + if (!result.Success) + { + this.logger?.LogWarning( + "Failed to apply CpuSelection profile {ProfileName} to process {ProcessName} (PID: {ProcessId}). ErrorCode: {ErrorCode}. Message: {Message}", + profileName, + process.Name, + process.ProcessId, + result.ErrorCode, + result.Message); + + return false; + } + } + else + { + await this.SetLoadProcessProfileLegacyAffinityAsync(process, profile.ProcessorAffinity).ConfigureAwait(false); + } + return true; } + private Task SetLoadProcessProfilePriorityAsync(ProcessModel process, ProcessPriorityClass priority) => + this.loadProcessProfilePrioritySetter != null + ? this.loadProcessProfilePrioritySetter(process, priority) + : this.SetProcessPriority(process, priority); + + private Task SetLoadProcessProfileCpuSelectionAsync(ProcessModel process, CpuSelection selection) => + this.loadProcessProfileCpuSelectionSetter != null + ? this.loadProcessProfileCpuSelectionSetter(process, selection) + : this.SetProcessorAffinity(process, selection); + + private Task SetLoadProcessProfileLegacyAffinityAsync(ProcessModel process, long affinityMask) => + this.loadProcessProfileLegacyAffinitySetter != null + ? this.loadProcessProfileLegacyAffinitySetter(process, affinityMask) + : this.SetProcessorAffinity(process, affinityMask); + + private async Task TryGetTopologySnapshotAsync() + { + if (this.cpuTopologyProvider == null) + { + return null; + } + + try + { + return await this.cpuTopologyProvider.GetTopologySnapshotAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger?.LogWarning(ex, "Failed to get CPU topology snapshot for profile CpuSelection migration"); + return null; + } + } + public async Task RefreshProcessInfo(ProcessModel process) { await Task.Run(() => diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index 477fe46..27cadeb 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -111,6 +111,8 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // CoreMaskService needs IServiceProvider for checking profile references @@ -118,7 +120,9 @@ private static IServiceCollection ConfigureCoreSystemServices(this IServiceColle { var logger = sp.GetRequiredService>(); var cpuTopologyService = sp.GetRequiredService(); - return new CoreMaskService(logger, cpuTopologyService, sp); + var cpuTopologyProvider = sp.GetRequiredService(); + var migrationService = sp.GetRequiredService(); + return new CoreMaskService(logger, cpuTopologyService, sp, cpuTopologyProvider, migrationService); }); return services; diff --git a/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs new file mode 100644 index 0000000..fef721d --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/CpuSelectionMigrationServiceTests.cs @@ -0,0 +1,261 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Diagnostics; + using System.Text.Json; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CpuSelectionMigrationServiceTests + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + [Fact] + public void MigrateFromLegacyAffinityMask_WithSingleGroupBelow64_SelectsExpectedProcessors() + { + var topology = CreateTopology(8); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyAffinityMask(0b0101, topology); + + Assert.Equal([0, 2], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.CreatedFromLegacyAffinityMask); + Assert.False(result.Metadata.ReviewRequired); + Assert.Equal(0b0101, service.BuildLegacyAffinityMaskIfRepresentable(result.Selection)); + } + + [Fact] + public void MigrateFromLegacyAffinityMask_OnTopologyAbove64_DoesNotAliasCpu64ToCpu0() + { + var topology = CreateTopology(65); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyAffinityMask(1, topology); + var cpu64Selection = CpuSelection.FromProcessors( + [topology.LogicalProcessors.Single(processor => processor.GlobalIndex == 64)], + topology); + + Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); + Assert.DoesNotContain(result.Selection.LogicalProcessors, processor => processor.GlobalIndex == 64); + Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(cpu64Selection)); + } + + [Fact] + public void BuildLegacyAffinityMaskIfRepresentable_WithGroupOneCpuZero_ReturnsNull() + { + var group1Cpu0 = new ProcessorRef(1, 0, 64); + var topology = CpuTopologySnapshot.Create([new ProcessorRef(0, 0, 0), group1Cpu0]); + var selection = CpuSelection.FromProcessors([group1Cpu0], topology); + var service = new CpuSelectionMigrationService(); + + var legacyMask = service.BuildLegacyAffinityMaskIfRepresentable(selection); + + Assert.Null(legacyMask); + } + + [Fact] + public void MigrateFromLegacyCoreMask_WhenShorterThanTopology_SelectsPresentIndexesAndRequiresReview() + { + var topology = CreateTopology(4); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyCoreMask([true, false], topology); + + Assert.Equal([0], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.CreatedFromLegacyCoreMask); + Assert.True(result.Metadata.ReviewRequired); + } + + [Fact] + public void MigrateFromLegacyCoreMask_WhenLongerThanTopology_IgnoresExtrasAndRequiresReview() + { + var topology = CreateTopology(2); + var service = new CpuSelectionMigrationService(); + + var result = service.MigrateFromLegacyCoreMask([false, true, true, true], topology); + + Assert.Equal([1], result.Selection.GlobalLogicalProcessorIndexes); + Assert.True(result.Metadata.ReviewRequired); + } + + [Fact] + public void MigrateProcessProfile_WithExistingCpuSelection_DoesNotOverwriteFromLegacyMask() + { + var topology = CreateTopology(4); + var existingSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 0b0101, + CpuSelection = existingSelection, + }; + var service = new CpuSelectionMigrationService(); + + var migrated = service.MigrateProcessProfile(profile, topology); + + Assert.Equal([1], migrated.CpuSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal(0b0101, migrated.ProcessorAffinity); + } + + [Fact] + public void ShouldRequireReview_TracksTopologySignatureChanges() + { + var topology = CreateTopology(4); + var changedTopology = CreateTopology(6); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology); + var service = new CpuSelectionMigrationService(); + + Assert.False(service.ShouldRequireReview(selection, topology.Signature, topology)); + Assert.True(service.ShouldRequireReview(selection, topology.Signature, changedTopology)); + } + + [Fact] + public void PrepareProcessProfileForSave_WithSingleGroupBelow64_SavesLegacyMask() + { + var topology = CreateTopology(4); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[0], topology.LogicalProcessors[2]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0, + CpuSelection = selection, + }; + var service = new CpuSelectionMigrationService(); + + var prepared = service.PrepareProcessProfileForSave(profile, topology); + + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, prepared.ProfileSchemaVersion); + Assert.Equal(0b0101, prepared.ProcessorAffinity); + Assert.NotNull(prepared.CpuSelection); + } + + [Fact] + public void PrepareProcessProfileForSave_WithCpu64_DoesNotProduceLegacyMask() + { + var topology = CreateTopology(65); + var selection = CpuSelection.FromProcessors([topology.LogicalProcessors[64]], topology); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0b11, + CpuSelection = selection, + }; + var service = new CpuSelectionMigrationService(); + + var prepared = service.PrepareProcessProfileForSave(profile, topology); + + Assert.Equal(0b11, prepared.ProcessorAffinity); + Assert.Null(service.BuildLegacyAffinityMaskIfRepresentable(prepared.CpuSelection!)); + } + + [Fact] + public void LegacyProcessProfileWithoutSchemaVersion_DeserializesAsVersionOne() + { + const string json = """ + { + "processName": "game.exe", + "priority": 2, + "processorAffinity": 5 + } + """; + + var profile = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(profile); + Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, profile.ProfileSchemaVersion); + Assert.Equal(5, profile.ProcessorAffinity); + } + + [Fact] + public void ProcessProfileWithCpuSelection_DeserializesAsVersionTwo() + { + var topology = CreateTopology(2); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + + var json = JsonSerializer.Serialize(profile); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); + Assert.NotNull(deserialized.CpuSelection); + Assert.Equal([0], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); + } + + [Fact] + public void LegacyCoreMaskWithoutSchemaVersion_DeserializesAsVersionOne() + { + const string json = """ + { + "id": "mask-1", + "name": "Legacy mask", + "description": "legacy", + "boolMask": [true, false, true], + "isDefault": false, + "isEnabled": true + } + """; + + var mask = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(mask); + Assert.Equal(CpuAffinityProfileSchemaVersions.Legacy, mask.ProfileSchemaVersion); + Assert.Equal([true, false, true], mask.BoolMask.ToArray()); + } + + [Fact] + public void CoreMaskWithCpuSelection_DeserializesAsVersionTwo() + { + var topology = CreateTopology(2); + var mask = new CoreMask + { + Name = "V2 mask", + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[1]], topology), + }; + mask.BoolMask.Add(false); + mask.BoolMask.Add(true); + + var json = JsonSerializer.Serialize(mask); + var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(deserialized); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, deserialized.ProfileSchemaVersion); + Assert.NotNull(deserialized.CpuSelection); + Assert.Equal([1], deserialized.CpuSelection!.GlobalLogicalProcessorIndexes); + Assert.Equal([false, true], deserialized.BoolMask.ToArray()); + } + + private static CpuTopologySnapshot CreateTopology(int processorCount) + { + var processors = Enumerable + .Range(0, processorCount) + .Select(index => new ProcessorRef((ushort)(index / 64), (byte)(index % 64), index)) + .ToList(); + + return CpuTopologySnapshot.Create( + processors, + signature: new CpuTopologySignature + { + CpuBrand = "Synthetic CPU", + LogicalProcessorCount = processorCount, + PhysicalCoreCount = processorCount, + ProcessorGroupCount = Math.Max(1, (processorCount + 63) / 64), + Source = "Test", + }); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs index b4f1052..dcc0b57 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessServiceTests.cs @@ -49,6 +49,49 @@ public async Task SaveProcessProfile_WritesExpectedJson() } } + [Fact] + public async Task SaveProcessProfile_WithTopologyProvider_WritesCpuSelectionSchema() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + new ProcessorRef(0, 2, 2), + ]); + var process = new ProcessModel + { + Name = "game.exe", + Priority = ProcessPriorityClass.High, + ProcessorAffinity = 0b101, + }; + + try + { + var service = CreateService(profilesDirectory, new FakeCpuTopologyProvider(topology)); + + var result = await service.SaveProcessProfile(profileName, process); + + Assert.True(result); + + var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); + var profile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + Assert.NotNull(profile); + Assert.Equal(CpuAffinityProfileSchemaVersions.CpuSelection, profile.ProfileSchemaVersion); + Assert.Equal(0b101, profile.ProcessorAffinity); + Assert.NotNull(profile.CpuSelection); + Assert.Equal([0, 2], profile.CpuSelection!.GlobalLogicalProcessorIndexes); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + [Fact] public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing() { @@ -68,6 +111,114 @@ public async Task LoadProcessProfile_ReturnsFalse_WhenFileIsMissing() } } + [Fact] + public async Task LoadProcessProfile_WithCpuSelectionApplyFailure_ReturnsFalse() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CreateTopology(); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + var profileApplier = new FakeLoadProcessProfileApplier( + cpuSelectionResult: AffinityApplyResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + "Affinity was not applied.", + "simulated apply failure")); + var service = CreateService( + profilesDirectory, + new FakeCpuTopologyProvider(topology), + profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.False(result); + Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); + Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithCpuSelectionApplySuccess_ReturnsTrue() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var topology = CreateTopology(); + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 1, + ProfileSchemaVersion = CpuAffinityProfileSchemaVersions.CpuSelection, + CpuSelection = CpuSelection.FromProcessors([topology.LogicalProcessors[0]], topology), + }; + var profileApplier = new FakeLoadProcessProfileApplier( + cpuSelectionResult: AffinityApplyResult.SucceededWithCpuSets("simulated apply success")); + var service = CreateService( + profilesDirectory, + new FakeCpuTopologyProvider(topology), + profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.True(result); + Assert.Equal(1, profileApplier.CpuSelectionApplyCalls); + Assert.Equal(0, profileApplier.LegacyAffinityApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + + [Fact] + public async Task LoadProcessProfile_WithoutTopologyProvider_UsesLegacyAffinityPath() + { + var profilesDirectory = CreateTemporaryDirectory(); + var profileName = $"profile-{Guid.NewGuid():N}"; + var profile = new ProcessProfileSnapshot + { + ProcessName = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0b11, + }; + var profileApplier = new FakeLoadProcessProfileApplier(); + var service = CreateService(profilesDirectory, topologyProvider: null, profileApplier); + + try + { + await WriteProfileAsync(profilesDirectory, profileName, profile); + + var result = await service.LoadProcessProfile(profileName, CreateProcess()); + + Assert.True(result); + Assert.Equal(1, profileApplier.LegacyAffinityApplyCalls); + Assert.Equal(0b11, profileApplier.LastLegacyAffinityMask); + Assert.Equal(0, profileApplier.CpuSelectionApplyCalls); + } + finally + { + DeleteDirectory(profilesDirectory); + } + } + [Fact] public void IsPassiveProcessAccessException_ReturnsTrue_ForModuleEnumerationFailure() { @@ -153,8 +304,55 @@ public void UntrackProcess_ClearsTrackedState() } } - private static ProcessService CreateService(string profilesDirectory) => - new(null, null, () => profilesDirectory); + private static ProcessService CreateService( + string profilesDirectory, + ICpuTopologyProvider? topologyProvider = null, + FakeLoadProcessProfileApplier? profileApplier = null) + { + if (profileApplier == null) + { + return new(null, null, () => profilesDirectory, cpuTopologyProvider: topologyProvider); + } + + return new ProcessService( + null, + null, + () => profilesDirectory, + foregroundProcessService: null, + processClassifier: null, + passiveProcessErrorThrottle: null, + cpuTopologyProvider: topologyProvider, + cpuSelectionMigrationService: null, + loadProcessProfilePrioritySetter: profileApplier.SetPriorityAsync, + loadProcessProfileCpuSelectionSetter: profileApplier.SetCpuSelectionAsync, + loadProcessProfileLegacyAffinitySetter: profileApplier.SetLegacyAffinityAsync); + } + + private static ProcessModel CreateProcess() => + new() + { + ProcessId = 1234, + Name = "game.exe", + Priority = ProcessPriorityClass.Normal, + ProcessorAffinity = 0, + }; + + private static CpuTopologySnapshot CreateTopology() => + CpuTopologySnapshot.Create( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 1, 1), + ]); + + private static Task WriteProfileAsync( + string profilesDirectory, + string profileName, + ProcessProfileSnapshot profile) + { + var filePath = Path.Combine(profilesDirectory, $"{profileName}.json"); + var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + return File.WriteAllTextAsync(filePath, json); + } private static string CreateTemporaryDirectory() { @@ -183,5 +381,48 @@ private static ConcurrentDictionary GetPrivateDictionary)(field?.GetValue(service) ?? throw new InvalidOperationException($"Field '{fieldName}' not found.")); } + + private sealed class FakeCpuTopologyProvider(CpuTopologySnapshot snapshot) : ICpuTopologyProvider + { + public Task GetTopologySnapshotAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(snapshot); + } + + private sealed class FakeLoadProcessProfileApplier + { + private readonly AffinityApplyResult cpuSelectionResult; + + public FakeLoadProcessProfileApplier(AffinityApplyResult? cpuSelectionResult = null) + { + this.cpuSelectionResult = cpuSelectionResult ?? AffinityApplyResult.Succeeded(0, 0); + } + + public int CpuSelectionApplyCalls { get; private set; } + + public int LegacyAffinityApplyCalls { get; private set; } + + public long LastLegacyAffinityMask { get; private set; } + + public Task SetPriorityAsync(ProcessModel process, ProcessPriorityClass priority) + { + process.Priority = priority; + return Task.CompletedTask; + } + + public Task SetCpuSelectionAsync(ProcessModel process, CpuSelection selection) + { + this.CpuSelectionApplyCalls++; + return Task.FromResult(this.cpuSelectionResult); + } + + public Task SetLegacyAffinityAsync(ProcessModel process, long affinityMask) + { + this.LegacyAffinityApplyCalls++; + this.LastLegacyAffinityMask = affinityMask; + process.ProcessorAffinity = affinityMask; + return Task.CompletedTask; + } + } } }