diff --git a/Installer/Installer.iss b/Installer/Installer.iss index 25ef063..a3b2eaf 100644 --- a/Installer/Installer.iss +++ b/Installer/Installer.iss @@ -5,7 +5,7 @@ #define MyAppPublisher "ThreadPilot" #define MyAppURL "https://github.com/" #define MyAppExeName "ThreadPilot.exe" -#define MyAppVersion "1.5.3" +#define MyAppVersion "1.6.0" #ifndef MyWizardStyle #define MyWizardStyle "modern dynamic windows11" diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs index bb5d8f0..0563c60 100644 --- a/Installer/ThreadPilot.wxs +++ b/Installer/ThreadPilot.wxs @@ -7,7 +7,7 @@ diff --git a/Installer/setup.iss b/Installer/setup.iss index b8e0a1b..0c75239 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.5.3" + #define MyAppVersion "1.6.0" #endif #ifndef MyAppSourceDir diff --git a/Locales/de-DE.xaml b/Locales/de-DE.xaml index 728288c..3116589 100644 --- a/Locales/de-DE.xaml +++ b/Locales/de-DE.xaml @@ -264,10 +264,10 @@ Aktivieren Sie den ausgewählten Windows-Energieplan Ausstehende Kernmaske - Wählen Sie eine Maske aus, um sie bereitzustellen. Dadurch wird die Betriebssystemaffinität erst geändert, wenn auf „Affinität anwenden“ geklickt wird. - Wenden Sie Affinität an - Wenden Sie die ausstehende Kernmaske auf den ausgewählten Prozess an und überprüfen Sie den Affinitätsstatus Windows - Affinität anwenden und als Regel speichern + Wählen Sie eine Maske zum Vormerken. Erst CPU-Zuweisung anwenden ändert den Zustand. + CPU-Zuweisung anwenden + Wendet die CPU-Auswahl im gewählten Modus an und überprüft den Windows-Status + CPU-Zuweisung anwenden und als Regel speichern Aktuelle Prozesseinstellungen grundsätzlich speichern. Diese Einstellungen werden automatisch angewendet, wenn dieser Prozess in Zukunft startet. Legen Sie die Priorität CPU fest @@ -306,7 +306,7 @@ CPU-Auslastung Speicherauslastung - Affinität in der Benutzeroberfläche inszeniert. Es wird erst angewendet, wenn auf „Affinität anwenden“ geklickt wird. + CPU-Auswahl vorgemerkt. Sie wird erst über CPU-Zuweisung anwenden übernommen. Affinität, die derzeit von Windows für den ausgewählten Prozess gemeldet wird Schreibgeschützte Vorschau der aktuellen oder ausstehenden CPU-Auswahl Öffnen Sie die Registerkarte CPU Masken, um eine neue benutzerdefinierte Maske zu erstellen @@ -626,11 +626,11 @@ Game Boost deaktiviert Game Boost-Modus nach {0} deaktiviert Fehler im Prozessmonitor - Affinität blockiert - Affinität angewendet - Affinität angepasst - Affinität ist fehlgeschlagen - Affinitätsfehler + CPU-Zuweisung blockiert + CPU-Zuweisung angewendet + CPU-Zuweisung angepasst + CPU-Zuweisung fehlgeschlagen + Fehler bei der CPU-Zuweisung Priorität blockiert Vorrangige Warnung Priorität angewendet @@ -659,6 +659,28 @@ Einstellungen konnten nicht gespeichert werden Dies ist eine Testbenachrichtigung, um zu überprüfen, ob Ihre Einstellungen korrekt funktionieren. + + CPU-Zuweisung + Standardmäßige CPU-Zuweisung + Legt fest, wie neue manuelle Aktionen und Regeln die ausgewählten CPUs zuweisen. + Informationen zum CPU-Zuweisungsmodus + Affinitätsmaske + Harte Einschränkung. Multi-Group-Auswahlen werden pro Thread angewendet; neue Threads werden bei aktiver Automatisierungsüberwachung erfasst. + Idealer Prozessor + Weiche Präferenz pro Thread mit stabiler Round-Robin-Verteilung. Neue Threads werden bei aktiver Automatisierungsüberwachung erfasst. + CPU-Sets + Weiche prozessweite Zuweisung, die neue Threads erben. Der explizite Modus fällt nie auf Affinität zurück. + ThreadPilot automatisch + Kompatibel mit alten Regeln: CPU-Sets mit Affinitäts-Fallback nur, wenn es sicher ist. + Wird für neue Aktionen und Regeln verwendet, sofern keine gespeicherte Regel sie überschreibt. + Reservierte CPU-Sets sind hier schreibgeschützt. Sie gelten systemweit, erfordern einen Neustart und sind für dedizierte Soft-Real-Time-Geräte vorgesehen; unvollständige Konfiguration kann schwere Fehlfunktionen verursachen. + ThreadPilot ändert ReservedCpuSets nicht. Die obigen prozessbezogenen Modi sind von dieser globalen Windows-Konfiguration unabhängig. + Reservierte CPU-Sets: Status nicht verfügbar. + Reservierte CPU-Sets: keine erkannt. + Reservierte CPU-Sets auf logischen Prozessoren erkannt: {0}. + CPU-Zuweisungsmodus + Dieser vorgemerkte Modus folgt einer passenden Regel; andernfalls gilt der globale Standard. + ThreadPilot erfordert Administratorrechte, um Prozessaffinität und Energiepläne zu verwalten. Möchten Sie die Anwendung mit Administratorrechten neu starten? Administratorrechte erforderlich diff --git a/Locales/en-US.xaml b/Locales/en-US.xaml index 0dce02e..00e3f0c 100644 --- a/Locales/en-US.xaml +++ b/Locales/en-US.xaml @@ -264,10 +264,10 @@ Activate the selected Windows power plan Pending core mask - Select a mask to stage it. This does not change OS affinity until Apply Affinity is clicked. - Apply Affinity - Apply the pending core mask to the selected process and verify the Windows affinity state - Apply Affinity and Save as Rule + Select a mask to stage it. Nothing changes until Apply CPU Assignment is clicked. + Apply CPU Assignment + Apply the pending CPU selection with the selected mode and verify the Windows state + Apply CPU Assignment and Save as Rule Save current process settings as a rule. These settings will be automatically applied when this process starts in the future. Set CPU Priority @@ -306,7 +306,7 @@ CPU Usage Memory Usage - Affinity staged in the UI. It is not applied until Apply Affinity is clicked. + CPU selection staged in the UI. It is applied only through Apply CPU Assignment. Affinity currently reported by Windows for the selected process Read-only preview of current or pending CPU selection Open CPU Masks tab to create a new custom mask @@ -626,11 +626,11 @@ Game Boost Deactivated Game Boost mode deactivated after {0} Process Monitor Error - Affinity blocked - Affinity applied - Affinity adjusted - Affinity failed - Affinity error + CPU assignment blocked + CPU assignment applied + CPU assignment adjusted + CPU assignment failed + CPU assignment error Priority blocked Priority warning Priority applied @@ -659,6 +659,28 @@ Failed to save settings This is a test notification to verify your settings are working correctly. + + CPU assignment + Default CPU assignment + Choose how new manual actions and rules assign the selected CPUs. + CPU assignment mode information + Affinity Mask + Hard constraint. Multi-group selections are applied per thread; new threads are covered while automation monitoring is enabled. + Ideal Processor + Soft per-thread preference with stable round-robin distribution. New threads are covered while automation monitoring is enabled. + CPU Sets + Soft process-wide assignment inherited by new threads. Explicit mode never falls back to affinity. + ThreadPilot automatic + Legacy-compatible behavior: CPU Sets with affinity fallback only when safe. + Used by new actions and rules unless a saved rule overrides it. + Reserved CPU Sets are read-only here. They are system-wide, require a restart, and are intended for dedicated Soft Real-Time devices; incomplete configuration can cause serious malfunction. + ThreadPilot does not modify ReservedCpuSets. Per-process CPU assignment modes above are independent from this global Windows configuration. + Reserved CPU Sets: status unavailable. + Reserved CPU Sets: none detected. + Reserved CPU Sets detected on logical processors: {0}. + CPU assignment mode + This staged mode follows the matching rule when one exists; otherwise it uses the global default. + ThreadPilot requires administrator privileges to manage process affinity and power plans. Would you like to restart the application with administrator privileges? Administrator Privileges Required diff --git a/Locales/es-ES.xaml b/Locales/es-ES.xaml index 3a7d75c..bc1b81f 100644 --- a/Locales/es-ES.xaml +++ b/Locales/es-ES.xaml @@ -264,10 +264,10 @@ Activar el plan de energía Windows seleccionado Máscara central pendiente - Selecciona una máscara para ponerla en escena. Esto no cambia la afinidad del sistema operativo hasta que se hace clic en Aplicar afinidad. - Aplicar afinidad - Aplique la máscara central pendiente al proceso seleccionado y verifique el estado de afinidad Windows - Aplicar afinidad y guardar como regla + Selecciona una máscara pendiente. Nada cambia hasta pulsar Aplicar asignación de CPU. + Aplicar asignación de CPU + Aplica la selección de CPU con el modo elegido y verifica el estado de Windows + Aplicar asignación de CPU y guardar como regla Guarde la configuración del proceso actual como regla. Esta configuración se aplicará automáticamente cuando este proceso comience en el futuro. Establecer prioridad CPU @@ -306,7 +306,7 @@ Uso de CPU Uso de la memoria - Afinidad puesta en escena en la interfaz de usuario. No se aplica hasta que se hace clic en Aplicar afinidad. + Selección de CPU pendiente. Solo se aplica mediante Aplicar asignación de CPU. Afinidad reportada actualmente por Windows para el proceso seleccionado Vista previa de solo lectura de la selección CPU actual o pendiente Abra la pestaña Máscaras CPU para crear una nueva máscara personalizada @@ -626,11 +626,11 @@ Game Boost desactivado Modo Game Boost desactivado después de {0} Error del monitor de proceso - Afinidad bloqueada - Afinidad aplicada - Afinidad ajustada - La afinidad falló - error de afinidad + Asignación de CPU bloqueada + Asignación de CPU aplicada + Asignación de CPU ajustada + Error en la asignación de CPU + Error de asignación de CPU Prioridad bloqueada Advertencia de prioridad Prioridad aplicada @@ -659,6 +659,28 @@ No se pudo guardar la configuración Esta es una notificación de prueba para verificar que su configuración esté funcionando correctamente. + + Asignación de CPU + Asignación de CPU predeterminada + Elige cómo las nuevas acciones manuales y reglas asignan las CPU seleccionadas. + Información del modo de asignación de CPU + Máscara de afinidad + Restricción rígida. Las selecciones de varios grupos se aplican por hilo; los nuevos hilos se incluyen con la supervisión automática activa. + Procesador ideal + Preferencia flexible por hilo con distribución estable. Los nuevos hilos se incluyen con la supervisión automática activa. + Conjuntos de CPU + Asignación flexible para todo el proceso heredada por los nuevos hilos. El modo explícito nunca recurre a la afinidad. + ThreadPilot automático + Compatible con reglas antiguas: CPU Sets con afinidad como alternativa solo cuando es seguro. + Se usa en nuevas acciones y reglas salvo que una regla guardada la reemplace. + Los CPU Sets reservados son de solo lectura aquí. Son globales, requieren reinicio y están destinados a dispositivos Soft Real-Time dedicados; una configuración incompleta puede causar fallos graves. + ThreadPilot no modifica ReservedCpuSets. Los modos por proceso anteriores son independientes de esta configuración global de Windows. + CPU Sets reservados: estado no disponible. + CPU Sets reservados: no se detectó ninguno. + CPU Sets reservados detectados en los procesadores lógicos: {0}. + Modo de asignación de CPU + Este modo pendiente sigue la regla coincidente si existe; de lo contrario usa el valor global predeterminado. + ThreadPilot requiere privilegios de administrador para gestionar la afinidad de procesos y los planes de energía. ¿Le gustaría reiniciar la aplicación con privilegios de administrador? Privilegios de administrador requeridos diff --git a/Locales/fr-FR.xaml b/Locales/fr-FR.xaml index 9063c96..0224ff8 100644 --- a/Locales/fr-FR.xaml +++ b/Locales/fr-FR.xaml @@ -264,10 +264,10 @@ Activer le plan d'alimentation Windows sélectionné Masque de base en attente - Sélectionnez un masque pour le mettre en scène. Cela ne modifie pas l'affinité du système d'exploitation jusqu'à ce que vous cliquiez sur Appliquer l'affinité. - Appliquer l'affinité - Appliquez le masque principal en attente au processus sélectionné et vérifiez l'état d'affinité Windows - Appliquer l'affinité et enregistrer en tant que règle + Sélectionnez un masque à préparer. Rien ne change avant Appliquer l’affectation CPU. + Appliquer l’affectation CPU + Applique la sélection CPU avec le mode choisi et vérifie l’état Windows + Appliquer l’affectation CPU et enregistrer la règle En règle générale, enregistrez les paramètres de processus actuels. Ces paramètres seront automatiquement appliqués lorsque ce processus démarrera à l’avenir. Définir la priorité CPU @@ -306,7 +306,7 @@ CPU Utilisation Utilisation de la mémoire - Affinité mise en scène dans l'interface utilisateur. Il n’est pas appliqué tant que vous n’avez pas cliqué sur Appliquer l’affinité. + Sélection CPU préparée dans l’interface. Elle est appliquée uniquement via Appliquer l’affectation CPU. Affinité actuellement signalée par Windows pour le processus sélectionné Aperçu en lecture seule de la sélection CPU actuelle ou en attente Ouvrez l'onglet CPU Masques pour créer un nouveau masque personnalisé. @@ -626,11 +626,11 @@ Boost de jeu désactivé Mode Game Boost désactivé après {0} Erreur du moniteur de processus - Affinité bloquée - Affinité appliquée - Affinité ajustée - L'affinité a échoué - Erreur d'affinité + Affectation CPU bloquée + Affectation CPU appliquée + Affectation CPU ajustée + Échec de l’affectation CPU + Erreur d’affectation CPU Priorité bloquée Avertissement prioritaire Priorité appliquée @@ -659,6 +659,28 @@ Échec de l'enregistrement des paramètres Il s'agit d'une notification de test pour vérifier que vos paramètres fonctionnent correctement. + + Affectation du processeur + Affectation CPU par défaut + Choisissez comment les nouvelles actions manuelles et règles affectent les processeurs sélectionnés. + Informations sur le mode d’affectation CPU + Masque d’affinité + Contrainte stricte. Les sélections multi-groupes sont appliquées par thread ; les nouveaux threads sont couverts si la surveillance automatique est active. + Processeur idéal + Préférence souple par thread avec répartition stable. Les nouveaux threads sont couverts si la surveillance automatique est active. + Jeux de processeurs + Affectation souple par processus héritée par les nouveaux threads. Le mode explicite ne revient jamais à l’affinité. + ThreadPilot automatique + Comportement compatible avec les anciennes règles : CPU Sets puis affinité uniquement si cela est sûr. + Utilisée pour les nouvelles actions et règles, sauf remplacement dans une règle enregistrée. + Les CPU Sets réservés sont en lecture seule ici. Ils sont globaux, exigent un redémarrage et visent les appareils Soft Real-Time dédiés ; une configuration incomplète peut provoquer de graves dysfonctionnements. + ThreadPilot ne modifie pas ReservedCpuSets. Les modes par processus ci-dessus sont indépendants de cette configuration Windows globale. + CPU Sets réservés : état indisponible. + CPU Sets réservés : aucun détecté. + CPU Sets réservés détectés sur les processeurs logiques : {0}. + Mode d’affectation CPU + Ce mode préparé suit la règle correspondante si elle existe ; sinon il utilise la valeur globale par défaut. + ThreadPilot nécessite des privilèges d'administrateur pour gérer l'affinité des processus et les plans d'alimentation. Souhaitez-vous redémarrer l'application avec les privilèges d'administrateur ? Privilèges d'administrateur requis diff --git a/Locales/it-IT.xaml b/Locales/it-IT.xaml index f1d7923..5c316cc 100644 --- a/Locales/it-IT.xaml +++ b/Locales/it-IT.xaml @@ -264,10 +264,10 @@ Attiva il piano di risparmio energia Windows selezionato Maschera centrale in sospeso - Seleziona una maschera per metterla in scena. Ciò non modifica l'affinità del sistema operativo finché non si fa clic su Applica affinità. - Applica affinità - Applica la maschera core in sospeso al processo selezionato e verifica lo stato di affinità Windows - Applica affinità e Salva come regola + Seleziona una maschera da preparare. Nulla cambia finché non fai clic su Applica assegnazione CPU. + Applica assegnazione CPU + Applica la selezione CPU con la modalità scelta e verifica lo stato di Windows + Applica assegnazione CPU e salva come regola Salva le impostazioni del processo corrente come regola. Queste impostazioni verranno applicate automaticamente all'avvio di questo processo in futuro. Imposta la priorità CPU @@ -306,7 +306,7 @@ Utilizzo CPU Utilizzo della memoria - Affinità organizzata nell'interfaccia utente. Non viene applicato finché non si fa clic su Applica affinità. + Selezione CPU preparata nell’interfaccia. Viene applicata solo tramite Applica assegnazione CPU. Affinità attualmente segnalata da Windows per il processo selezionato Anteprima di sola lettura della selezione CPU corrente o in sospeso Apri la scheda CPU Maschere per creare una nuova maschera personalizzata @@ -626,11 +626,11 @@ Potenziamento del gioco disattivato Modalità Game Boost disattivata dopo {0} Errore di monitoraggio del processo - Affinità bloccata - Affinità applicata - Affinità modificata - Affinità fallita - Errore di affinità + Assegnazione CPU bloccata + Assegnazione CPU applicata + Assegnazione CPU corretta + Assegnazione CPU non riuscita + Errore di assegnazione CPU Priorità bloccata Avvertimento prioritario Priorità applicata @@ -659,6 +659,28 @@ Impossibile salvare le impostazioni Questa è una notifica di prova per verificare che le tue impostazioni funzionino correttamente. + + Assegnazione CPU + Assegnazione CPU predefinita + Scegli come le nuove azioni manuali e le regole assegnano le CPU selezionate. + Informazioni sulla modalità di assegnazione CPU + Maschera di affinità + Vincolo rigido. Le selezioni multi-group vengono applicate per thread; i nuovi thread sono coperti quando il monitoraggio automatico è attivo. + Processore ideale + Preferenza soft per thread con distribuzione round-robin stabile. I nuovi thread sono coperti quando il monitoraggio automatico è attivo. + CPU Sets + Assegnazione soft per processo ereditata dai nuovi thread. La modalità esplicita non usa fallback affinity. + ThreadPilot automatico + Comportamento compatibile con le vecchie regole: CPU Sets con fallback affinity solo quando sicuro. + Usata da nuove azioni e regole, salvo override in una regola salvata. + I CPU Set riservati sono qui in sola lettura. Sono globali, richiedono un riavvio e sono destinati a dispositivi Soft Real-Time dedicati; una configurazione incompleta può causare malfunzionamenti seri. + ThreadPilot non modifica ReservedCpuSets. Le modalità per processo sopra indicate sono indipendenti da questa configurazione globale di Windows. + CPU Set riservati: stato non disponibile. + CPU Set riservati: nessuno rilevato. + CPU Set riservati rilevati sui processori logici: {0}. + Modalità di assegnazione CPU + Questa modalità in sospeso segue la regola corrispondente, se presente; altrimenti usa il valore predefinito globale. + ThreadPilot richiede privilegi di amministratore per gestire l'affinità dei processi e i piani di alimentazione. Desideri riavviare l'applicazione con privilegi di amministratore? Privilegi di amministratore richiesti diff --git a/Locales/ru-RU.xaml b/Locales/ru-RU.xaml index 916989c..e445183 100644 --- a/Locales/ru-RU.xaml +++ b/Locales/ru-RU.xaml @@ -264,10 +264,10 @@ Активируйте выбранный план электропитания Windows. Ожидаемая основная маска - Выберите маску для ее создания. Привязка ОС не изменится до тех пор, пока не будет нажата кнопка Применить привязку. - Применить привязку - Примените ожидающую основную маску к выбранному процессу и проверьте состояние привязки Windows. - Применить привязку и сохранить как правило + Подготовьте маску. Изменения вступят в силу только после нажатия «Применить назначение ЦП». + Применить назначение ЦП + Применяет выбор ЦП в выбранном режиме и проверяет состояние Windows. + Применить назначение ЦП и сохранить правило Сохраните текущие настройки процесса как правило. Эти настройки будут автоматически применены при запуске этого процесса в будущем. Установить приоритет CPU @@ -306,7 +306,7 @@ Загрузка CPU Использование памяти - Привязка подготовлена в интерфейсе и будет применена только после нажатия кнопки «Применить привязку». + Выбор ЦП подготовлен и применяется только командой «Применить назначение ЦП». Текущая привязка выбранного процесса по данным Windows Предварительный просмотр текущего или ожидающего выбора CPU только для чтения Откройте вкладку «Маски» CPU, чтобы создать новую пользовательскую маску. @@ -626,11 +626,11 @@ Ускорение игры отключено Режим Game Boost отключен после {0} Ошибка монитора процесса - Родственность заблокирована - Привязка применена - Привязка скорректирована - Не удалось применить привязку - Ошибка привязки + Назначение ЦП заблокировано + Назначение ЦП применено + Назначение ЦП скорректировано + Не удалось назначить ЦП + Ошибка назначения ЦП Приоритет заблокирован Приоритетное предупреждение Приоритет применен @@ -659,6 +659,28 @@ Не удалось сохранить настройки. Это тестовое уведомление, позволяющее убедиться, что ваши настройки работают правильно. + + Назначение ЦП + Назначение ЦП по умолчанию + Выберите, как новые ручные действия и правила назначают выбранные ЦП. + Сведения о режиме назначения ЦП + Маска привязки + Жёсткое ограничение. Выбор нескольких групп применяется к потокам; новые потоки охватываются при включённом автоматическом мониторинге. + Идеальный процессор + Мягкое предпочтение для потоков со стабильным циклическим распределением. Новые потоки охватываются при включённом мониторинге. + Наборы ЦП + Мягкое назначение всему процессу, наследуемое новыми потоками. Явный режим не использует привязку как запасной вариант. + ThreadPilot автоматически + Совместимость со старыми правилами: CPU Sets с переходом к привязке только когда это безопасно. + Используется новыми действиями и правилами, если сохранённое правило не задаёт другой режим. + Зарезервированные CPU Sets здесь доступны только для чтения. Они действуют глобально, требуют перезагрузки и предназначены для выделенных устройств Soft Real-Time; неполная настройка может вызвать серьёзные сбои. + ThreadPilot не изменяет ReservedCpuSets. Режимы для процессов выше не зависят от этой глобальной настройки Windows. + Зарезервированные CPU Sets: состояние недоступно. + Зарезервированные CPU Sets: не обнаружены. + Зарезервированные CPU Sets обнаружены на логических процессорах: {0}. + Режим назначения ЦП + Этот подготовленный режим берётся из подходящего правила, а при его отсутствии — из глобальной настройки. + ThreadPilot требуются права администратора для управления привязкой процессов и планами электропитания. Хотите перезапустить приложение с правами администратора? Требуются права администратора diff --git a/Locales/zh-CN.xaml b/Locales/zh-CN.xaml index 2f08f8f..7b68060 100644 --- a/Locales/zh-CN.xaml +++ b/Locales/zh-CN.xaml @@ -264,10 +264,10 @@ 激活所选的 Windows 电源计划 待处理的核心掩码 - 选择一个掩码来暂存它。在点击“应用关联性”之前,这不会更改操作系统关联性。 - 应用关联性 - 将待处理的核心掩码应用于选定的进程,并验证 Windows 关联性状态 - 应用关联性并保存为规则 + 选择并暂存掩码。点击“应用 CPU 分配”前不会发生更改。 + 应用 CPU 分配 + 使用所选模式应用待处理 CPU 选择,并验证 Windows 状态 + 应用 CPU 分配并保存为规则 将当前进程设置保存为规则。以后此进程启动时将自动应用这些设置。 设置 CPU 优先级 @@ -306,7 +306,7 @@ CPU 使用率 内存使用 - 关联性已暂存在 UI 中。在点击“应用关联性”之前不会应用。 + CPU 选择已暂存,仅通过“应用 CPU 分配”应用。 Windows 当前报告的所选进程的关联性 当前或待处理 CPU 选择的只读预览 打开 CPU 掩码选项卡以创建新的自定义掩码 @@ -626,11 +626,11 @@ 游戏加速已关闭 游戏加速模式已关闭,持续时间: {0} 进程监控器错误 - 关联性应用被阻止 - 关联性已应用 - 关联性已调整 - 关联性应用失败 - 关联性错误 + CPU 分配被阻止 + CPU 分配已应用 + CPU 分配已调整 + CPU 分配失败 + CPU 分配错误 优先级应用被阻止 优先级警告 优先级已应用 @@ -659,6 +659,28 @@ 无法保存设置 这是一条用于验证您的设置是否正常工作的测试通知。 + + CPU 分配 + 默认 CPU 分配 + 选择新手动操作和规则如何分配所选 CPU。 + CPU 分配模式信息 + 关联掩码 + 硬性限制。多处理器组选择按线程应用;启用自动监控时会覆盖新线程。 + 理想处理器 + 按线程的软偏好,采用稳定轮询分配。启用自动监控时会覆盖新线程。 + CPU 集 + 进程级软分配,新线程会继承。显式模式绝不会回退到关联性。 + ThreadPilot 自动 + 兼容旧规则:先使用 CPU 集,仅在安全时回退到关联性。 + 用于新操作和规则,除非保存的规则指定了覆盖模式。 + 此处仅显示保留 CPU 集。它是全局设置、需要重启,并面向专用 Soft Real-Time 设备;配置不完整可能导致严重故障。 + ThreadPilot 不会修改 ReservedCpuSets。上面的进程级模式独立于此 Windows 全局配置。 + 保留 CPU 集:状态不可用。 + 保留 CPU 集:未检测到。 + 在以下逻辑处理器上检测到保留 CPU 集:{0}。 + CPU 分配模式 + 若有匹配规则,此暂存模式遵循该规则;否则使用全局默认值。 + ThreadPilot 需要管理员权限来管理进程关联性和电源计划。 您想以管理员权限重新启动应用程序吗? 需要管理员权限 diff --git a/Models/ApplicationSettingsModel.cs b/Models/ApplicationSettingsModel.cs index 62a2032..094b9a9 100644 --- a/Models/ApplicationSettingsModel.cs +++ b/Models/ApplicationSettingsModel.cs @@ -156,8 +156,11 @@ public partial class ApplicationSettingsModel : ObservableObject, IModel [ObservableProperty] private bool applyPersistentRulesOnProcessStart = true; - [ObservableProperty] - private bool enableAutomationMonitoring = true; + [ObservableProperty] + private bool enableAutomationMonitoring = true; + + [ObservableProperty] + private CpuAssignmentMode defaultCpuAssignmentMode = CpuAssignmentMode.Automatic; // Advanced Settings [ObservableProperty] @@ -246,7 +249,10 @@ public void CopyFrom(ApplicationSettingsModel other) this.EnableWmiMonitoring = other.EnableWmiMonitoring; this.EnableFallbackPolling = other.EnableFallbackPolling; this.ApplyPersistentRulesOnProcessStart = other.ApplyPersistentRulesOnProcessStart; - this.EnableAutomationMonitoring = other.EnableAutomationMonitoring; + this.EnableAutomationMonitoring = other.EnableAutomationMonitoring; + this.DefaultCpuAssignmentMode = Enum.IsDefined(other.DefaultCpuAssignmentMode) + ? other.DefaultCpuAssignmentMode + : CpuAssignmentMode.Automatic; // Advanced Settings this.EnableDebugLogging = other.EnableDebugLogging; @@ -280,10 +286,15 @@ public ValidationResult Validate() errors.Add("Process polling interval must be between 1 and 60 seconds"); } - if (this.FallbackPollingIntervalMs < 1000 || this.FallbackPollingIntervalMs > 60000) - { - errors.Add("Fallback polling interval must be between 1 and 60 seconds"); - } + if (this.FallbackPollingIntervalMs < 1000 || this.FallbackPollingIntervalMs > 60000) + { + errors.Add("Fallback polling interval must be between 1 and 60 seconds"); + } + + if (!Enum.IsDefined(this.DefaultCpuAssignmentMode)) + { + errors.Add("Default CPU assignment mode is invalid"); + } return errors.Count == 0 ? ValidationResult.Success() : ValidationResult.Failure(errors.ToArray()); } diff --git a/Models/CpuSelection.cs b/Models/CpuSelection.cs index f16985c..0d1e78d 100644 --- a/Models/CpuSelection.cs +++ b/Models/CpuSelection.cs @@ -1,8 +1,16 @@ -namespace ThreadPilot.Models -{ +namespace ThreadPilot.Models +{ using System; using System.Collections.Generic; - using System.Linq; + using System.Linq; + + public enum CpuAssignmentMode + { + Automatic = 0, + AffinityMask, + IdealProcessor, + CpuSets, + } public readonly record struct ProcessorRef(ushort Group, byte LogicalProcessorNumber, int GlobalIndex); @@ -60,8 +68,9 @@ private CpuTopologySnapshot( IReadOnlyDictionary numaNodeIndexesByProcessor, IReadOnlyDictionary lastLevelCacheIndexesByProcessor, IReadOnlyDictionary packageIndexesByProcessor, - IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, - CpuTopologySignature signature) + IReadOnlyDictionary> smtSiblingGlobalIndexesByProcessor, + CpuTopologySignature signature, + IReadOnlyList reservedLogicalProcessorIndexes) { this.LogicalProcessors = logicalProcessors; this.cpuSetIdsByProcessor = cpuSetIdsByProcessor; @@ -71,12 +80,15 @@ private CpuTopologySnapshot( this.lastLevelCacheIndexesByProcessor = lastLevelCacheIndexesByProcessor; this.packageIndexesByProcessor = packageIndexesByProcessor; this.smtSiblingGlobalIndexesByProcessor = smtSiblingGlobalIndexesByProcessor; - this.Signature = signature; + this.Signature = signature; + this.ReservedLogicalProcessorIndexes = reservedLogicalProcessorIndexes; } public IReadOnlyList LogicalProcessors { get; } - public CpuTopologySignature Signature { get; } + public CpuTopologySignature Signature { get; } + + public IReadOnlyList ReservedLogicalProcessorIndexes { get; } public static CpuTopologySnapshot Create( IEnumerable logicalProcessors, @@ -87,7 +99,8 @@ public static CpuTopologySnapshot Create( IReadOnlyDictionary? numaNodeIndexes = null, IReadOnlyDictionary? lastLevelCacheIndexes = null, IReadOnlyDictionary? packageIndexes = null, - IReadOnlyDictionary>? smtSiblingGlobalIndexes = null) + IReadOnlyDictionary>? smtSiblingGlobalIndexes = null, + IEnumerable? reservedLogicalProcessorIndexes = null) { ArgumentNullException.ThrowIfNull(logicalProcessors); @@ -158,8 +171,13 @@ public static CpuTopologySnapshot Create( numaNodeIndexMap, lastLevelCacheIndexMap, packageIndexMap, - smtSiblingMap, - resolvedSignature); + smtSiblingMap, + resolvedSignature, + (reservedLogicalProcessorIndexes ?? []) + .Where(knownGlobalIndexes.Contains) + .Distinct() + .OrderBy(index => index) + .ToList()); } public bool TryGetCpuSetId(ProcessorRef processor, out uint cpuSetId) => diff --git a/Models/PersistentProcessRule.cs b/Models/PersistentProcessRule.cs index 90baf7d..c7e37aa 100644 --- a/Models/PersistentProcessRule.cs +++ b/Models/PersistentProcessRule.cs @@ -20,7 +20,9 @@ public sealed record PersistentProcessRule public CpuSelection? CpuSelection { get; init; } - public long? LegacyAffinityMask { get; init; } + public long? LegacyAffinityMask { get; init; } + + public CpuAssignmentMode CpuAssignmentMode { get; init; } = CpuAssignmentMode.Automatic; public ProcessPriorityClass? Priority { get; init; } diff --git a/Platforms/Windows/CpuSetNativeMethods.cs b/Platforms/Windows/CpuSetNativeMethods.cs index 0251668..72acb3a 100644 --- a/Platforms/Windows/CpuSetNativeMethods.cs +++ b/Platforms/Windows/CpuSetNativeMethods.cs @@ -15,7 +15,11 @@ internal static partial class CpuSetNativeMethods [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] - public static partial bool SetProcessDefaultCpuSets(SafeProcessHandle Process, uint[]? CpuSetIds, uint CpuSetIdCount); + public static partial bool SetProcessDefaultCpuSets(SafeProcessHandle Process, uint[]? CpuSetIds, uint CpuSetIdCount); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetProcessDefaultCpuSets(SafeProcessHandle Process, uint[]? CpuSetIds, uint CpuSetIdCount, out uint RequiredIdCount); [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] diff --git a/Platforms/Windows/IProcessCpuSetNativeApi.cs b/Platforms/Windows/IProcessCpuSetNativeApi.cs index bfd74da..956e4d5 100644 --- a/Platforms/Windows/IProcessCpuSetNativeApi.cs +++ b/Platforms/Windows/IProcessCpuSetNativeApi.cs @@ -8,7 +8,9 @@ internal interface IProcessCpuSetNativeApi { SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId); - bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount); + bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount); + + bool GetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount, out uint requiredIdCount); bool GetProcessTimes( SafeProcessHandle process, @@ -40,10 +42,15 @@ public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHand return CpuSetNativeMethods.OpenProcess(access, inheritHandle, processId); } - public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) { return CpuSetNativeMethods.SetProcessDefaultCpuSets(process, cpuSetIds, cpuSetIdCount); - } + } + + public bool GetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount, out uint requiredIdCount) + { + return CpuSetNativeMethods.GetProcessDefaultCpuSets(process, cpuSetIds, cpuSetIdCount, out requiredIdCount); + } public bool GetProcessTimes( SafeProcessHandle process, diff --git a/Platforms/Windows/ProcessCpuSetHandler.cs b/Platforms/Windows/ProcessCpuSetHandler.cs index ff79ffc..89b3589 100644 --- a/Platforms/Windows/ProcessCpuSetHandler.cs +++ b/Platforms/Windows/ProcessCpuSetHandler.cs @@ -254,7 +254,7 @@ public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool ArgumentNullException.ThrowIfNull(selection); - var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); + var cpuSetIds = this.cpuSetMapping.ResolveCpuSetIds(selection); if (cpuSetIds.Count == 0) { this.logger?.LogWarning( @@ -265,7 +265,16 @@ public CpuSetApplyResult ApplyCpuSelectionDetailed(CpuSelection? selection, bool AffinityApplyErrorCodes.InvalidTopology, ProcessOperationUserMessages.InvalidTopology, $"No valid CPU Set IDs resolved for CPU selection on process '{this.executableName}' (PID: {this.pid})."); - } + } + + var unavailableCpuSetIds = this.GetUnavailableAllocatedCpuSetIds(cpuSetIds); + if (unavailableCpuSetIds.Count > 0) + { + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.CpuSetsUnavailable, + ProcessOperationUserMessages.CpuSetsUnavailable, + $"CPU Set IDs allocated to another process cannot be used: {string.Join(", ", unavailableCpuSetIds)}."); + } var cpuSetIdsArray = cpuSetIds.ToArray(); return this.ApplyCpuSetIdsDetailed(cpuSetIdsArray, (uint)cpuSetIdsArray.Length, "apply CPU Set selection"); @@ -318,9 +327,17 @@ private bool ApplyCpuSetIds(uint[]? cpuSetIds, uint cpuSetIdCount, string operat private CpuSetApplyResult ApplyCpuSetIdsDetailed(uint[]? cpuSetIds, uint cpuSetIdCount, string operationName) { - bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount); - if (success) - { + bool success = this.nativeApi.SetProcessDefaultCpuSets(this.setLimitedInfoHandle!, cpuSetIds, cpuSetIdCount); + if (success) + { + if (!this.VerifyCpuSetIds(cpuSetIds ?? [], cpuSetIdCount)) + { + return CpuSetApplyResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + ProcessOperationUserMessages.CpuSetsUnavailable, + $"Windows did not verify the requested CPU Sets for '{this.executableName}' (PID: {this.pid})."); + } + this.logger?.LogInformation( "Completed {OperationName} for '{ExecutableName}' (PID: {ProcessId})", operationName, @@ -338,8 +355,75 @@ private CpuSetApplyResult ApplyCpuSetIdsDetailed(uint[]? cpuSetIds, uint cpuSetI } this.logger?.LogWarning(errorMessage); - return this.CreateNativeFailureResult(operationName, error, errorMessage); - } + return this.CreateNativeFailureResult(operationName, error, errorMessage); + } + + private bool VerifyCpuSetIds(uint[] expected, uint expectedCount) + { + if (!this.nativeApi.GetProcessDefaultCpuSets(this.queryLimitedInfoHandle!, null, 0, out var requiredCount) && requiredCount == 0) + { + return false; + } + + if (requiredCount == 0) + { + return expectedCount == 0; + } + + var observed = new uint[requiredCount]; + return this.nativeApi.GetProcessDefaultCpuSets(this.queryLimitedInfoHandle!, observed, requiredCount, out requiredCount) && + observed.Take((int)requiredCount).OrderBy(id => id).SequenceEqual(expected.Take((int)expectedCount).OrderBy(id => id)); + } + + private List GetUnavailableAllocatedCpuSetIds(IReadOnlyList selectedIds) + { + uint bufferLength = 0; + if (!this.nativeApi.GetSystemCpuSetInformation(IntPtr.Zero, 0, ref bufferLength, this.queryLimitedInfoHandle!, 0) && + this.nativeApi.GetLastWin32Error() != 0x7A) + { + return []; + } + + if (bufferLength == 0) + { + return []; + } + + var selected = selectedIds.ToHashSet(); + var unavailable = new List(); + var buffer = Marshal.AllocHGlobal((int)bufferLength); + try + { + if (!this.nativeApi.GetSystemCpuSetInformation(buffer, bufferLength, ref bufferLength, this.queryLimitedInfoHandle!, 0)) + { + return []; + } + + for (var offset = 0; offset < bufferLength;) + { + var item = Marshal.PtrToStructure(IntPtr.Add(buffer, offset)); + if (item.Size == 0) + { + break; + } + + const byte allocated = 1 << 1; + const byte allocatedToTarget = 1 << 2; + if (selected.Contains(item.Id) && (item.AllFlags & allocated) != 0 && (item.AllFlags & allocatedToTarget) == 0) + { + unavailable.Add(item.Id); + } + + offset += (int)item.Size; + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + + return unavailable; + } private CpuSetApplyResult CreateNativeFailureResult( string operationName, diff --git a/Platforms/Windows/ProcessThreadCpuAssignmentHandler.cs b/Platforms/Windows/ProcessThreadCpuAssignmentHandler.cs new file mode 100644 index 0000000..3b36363 --- /dev/null +++ b/Platforms/Windows/ProcessThreadCpuAssignmentHandler.cs @@ -0,0 +1,219 @@ +namespace ThreadPilot.Platforms.Windows +{ + using System.ComponentModel; + using System.Diagnostics; + using System.Runtime.InteropServices; + using Microsoft.Win32.SafeHandles; + using ThreadPilot.Models; + + internal sealed partial class ProcessThreadCpuAssignmentHandler + { + private const uint ThreadSetInformation = 0x0020; + private const uint ThreadQueryLimitedInformation = 0x0800; + private const int ErrorAccessDenied = 5; + private const int ErrorInvalidParameter = 87; + + private readonly int processId; + + public ProcessThreadCpuAssignmentHandler(int processId) + { + this.processId = processId; + } + + public ThreadCpuAssignmentResult ApplyIdealProcessors(IReadOnlyList processors) + { + var orderedProcessors = processors + .Distinct() + .OrderBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + if (orderedProcessors.Count == 0) + { + return ThreadCpuAssignmentResult.Failed("The CPU selection is empty."); + } + + return this.ApplyToThreads((threadHandle, index) => + { + var expected = ToProcessorNumber(orderedProcessors[index % orderedProcessors.Count]); + if (!NativeMethods.SetThreadIdealProcessorEx(threadHandle, in expected, IntPtr.Zero)) + { + return false; + } + + return NativeMethods.GetThreadIdealProcessorEx(threadHandle, out var observed) && + observed.Group == expected.Group && + observed.Number == expected.Number; + }); + } + + public ThreadCpuAssignmentResult ApplyGroupAffinity(IReadOnlyList processors) + { + var groups = BuildGroupAffinities(processors); + if (groups.Count == 0) + { + return ThreadCpuAssignmentResult.Failed("The CPU selection is empty."); + } + + return this.ApplyToThreads((threadHandle, index) => + { + var expected = groups[index % groups.Count]; + if (!NativeMethods.SetThreadGroupAffinity(threadHandle, in expected, IntPtr.Zero)) + { + return false; + } + + return NativeMethods.GetThreadGroupAffinity(threadHandle, out var observed) && + observed.Group == expected.Group && + observed.Mask == expected.Mask; + }); + } + + internal static IReadOnlyList BuildGroupAffinities(IEnumerable processors) => + processors + .Distinct() + .GroupBy(processor => processor.Group) + .OrderBy(group => group.Key) + .Select(group => new GroupAffinity + { + Group = group.Key, + Mask = (nuint)group.Aggregate(0UL, (mask, processor) => mask | (1UL << processor.LogicalProcessorNumber)), + }) + .Where(group => group.Mask != 0) + .ToList(); + + internal static IReadOnlyList BuildIdealProcessorSequence( + IEnumerable processors, + int threadCount) + { + var ordered = processors + .Distinct() + .OrderBy(processor => processor.Group) + .ThenBy(processor => processor.LogicalProcessorNumber) + .ToList(); + return ordered.Count == 0 || threadCount <= 0 + ? [] + : Enumerable.Range(0, threadCount).Select(index => ordered[index % ordered.Count]).ToList(); + } + + private ThreadCpuAssignmentResult ApplyToThreads(Func apply) + { + var applied = 0; + try + { + using var process = Process.GetProcessById(this.processId); + var threadIds = process.Threads.Cast() + .Select(thread => thread.Id) + .OrderBy(id => id) + .ToList(); + + for (var index = 0; index < threadIds.Count; index++) + { + using var handle = NativeMethods.OpenThread( + ThreadSetInformation | ThreadQueryLimitedInformation, + false, + (uint)threadIds[index]); + if (handle.IsInvalid) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorInvalidParameter) + { + continue; + } + + return ThreadCpuAssignmentResult.Failed( + new Win32Exception(error).Message, + applied, + error == ErrorAccessDenied); + } + + if (!apply(handle, index)) + { + var error = Marshal.GetLastWin32Error(); + if (error == ErrorInvalidParameter) + { + continue; + } + + return ThreadCpuAssignmentResult.Failed( + error == 0 ? "Windows did not verify the requested thread CPU assignment." : new Win32Exception(error).Message, + applied, + error == ErrorAccessDenied); + } + + applied++; + } + } + catch (ArgumentException) + { + return ThreadCpuAssignmentResult.Failed("The process exited before its threads could be configured.", applied); + } + catch (InvalidOperationException) + { + return ThreadCpuAssignmentResult.Failed("The process exited before its threads could be configured.", applied); + } + catch (Win32Exception ex) + { + return ThreadCpuAssignmentResult.Failed(ex.Message, applied, ex.NativeErrorCode == ErrorAccessDenied); + } + + return applied == 0 + ? ThreadCpuAssignmentResult.Failed("No live process threads were available.") + : ThreadCpuAssignmentResult.Succeeded(applied); + } + + private static ProcessorNumber ToProcessorNumber(ProcessorRef processor) => + new() + { + Group = processor.Group, + Number = processor.LogicalProcessorNumber, + }; + + [StructLayout(LayoutKind.Sequential)] + internal struct ProcessorNumber + { + public ushort Group; + public byte Number; + public byte Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct GroupAffinity + { + public nuint Mask; + public ushort Group; + public ushort Reserved0; + public ushort Reserved1; + public ushort Reserved2; + } + + private static partial class NativeMethods + { + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial SafeWaitHandle OpenThread(uint desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint threadId); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetThreadIdealProcessorEx(SafeWaitHandle thread, in ProcessorNumber idealProcessor, IntPtr previousIdealProcessor); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetThreadIdealProcessorEx(SafeWaitHandle thread, out ProcessorNumber idealProcessor); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetThreadGroupAffinity(SafeWaitHandle thread, in GroupAffinity groupAffinity, IntPtr previousGroupAffinity); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetThreadGroupAffinity(SafeWaitHandle thread, out GroupAffinity groupAffinity); + } + } + + internal sealed record ThreadCpuAssignmentResult(bool Success, int AppliedThreadCount, bool IsAccessDenied, string Error) + { + public static ThreadCpuAssignmentResult Succeeded(int count) => new(true, count, false, string.Empty); + + public static ThreadCpuAssignmentResult Failed(string error, int count = 0, bool accessDenied = false) => + new(false, count, accessDenied, error); + } +} diff --git a/Services/ActivityAuditService.cs b/Services/ActivityAuditService.cs index c9dea00..578c88b 100644 --- a/Services/ActivityAuditService.cs +++ b/Services/ActivityAuditService.cs @@ -142,8 +142,9 @@ public static ActivityAuditEntry Map(string action, string details, string? cont private static string ResolveCategory(string action) { - if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || - action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) + if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("ProcessCpuAssignment", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) { return ActivityAuditCategories.Affinity; } diff --git a/Services/AffinityApplyService.cs b/Services/AffinityApplyService.cs index 9b4c4a8..2f0e258 100644 --- a/Services/AffinityApplyService.cs +++ b/Services/AffinityApplyService.cs @@ -1,6 +1,7 @@ namespace ThreadPilot.Services { - using System.ComponentModel; + using System.ComponentModel; + using System.Diagnostics; using Microsoft.Extensions.Logging; using ThreadPilot.Models; using ThreadPilot.Platforms.Windows; @@ -26,7 +27,8 @@ public static class AffinityApplyErrorCodes public const string CpuSetsUnavailable = "CpuSetsUnavailable"; public const string LegacyFallbackUnsafe = "LegacyFallbackUnsafe"; public const string NativeApplyFailed = "NativeApplyFailed"; - public const string UnknownError = "UnknownError"; + public const string UnknownError = "UnknownError"; + public const string ProcessRestartRequired = "ProcessRestartRequired"; } public sealed record AffinityApplyResult @@ -57,7 +59,15 @@ public sealed record AffinityApplyResult public bool UsedCpuSets { get; init; } - public bool UsedLegacyAffinity { get; init; } + public bool UsedLegacyAffinity { get; init; } + + public bool UsedIdealProcessor { get; init; } + + public CpuAssignmentMode RequestedMode { get; init; } = CpuAssignmentMode.Automatic; + + public CpuAssignmentMode EffectiveMode { get; init; } = CpuAssignmentMode.Automatic; + + public int AppliedThreadCount { get; init; } public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMask) => new() @@ -68,7 +78,8 @@ public static AffinityApplyResult Succeeded(long requestedMask, long verifiedMas FailureReason = AffinityApplyFailureReason.None, ErrorCode = AffinityApplyErrorCodes.None, UserMessage = "Affinity applied successfully.", - TechnicalMessage = $"Affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", + TechnicalMessage = $"Affinity 0x{requestedMask:X} applied and verified as 0x{verifiedMask:X}.", + EffectiveMode = CpuAssignmentMode.AffinityMask, }; public static AffinityApplyResult SucceededWithCpuSets(string technicalMessage) => @@ -79,7 +90,8 @@ public static AffinityApplyResult SucceededWithCpuSets(string technicalMessage) ErrorCode = AffinityApplyErrorCodes.None, UserMessage = "Affinity applied successfully.", TechnicalMessage = technicalMessage, - UsedCpuSets = true, + UsedCpuSets = true, + EffectiveMode = CpuAssignmentMode.CpuSets, }; public static AffinityApplyResult SucceededWithLegacyFallback(long requestedMask, long verifiedMask) => @@ -147,9 +159,15 @@ private static string MapFailureReason(AffinityApplyFailureReason failureReason) public interface IAffinityApplyService { - Task ApplyAsync(ProcessModel process, long requestedMask); - - Task ApplyAsync(ProcessModel process, CpuSelection selection); + Task ApplyAsync(ProcessModel process, long requestedMask); + + Task ApplyAsync(ProcessModel process, long requestedMask, CpuAssignmentMode mode) => + this.ApplyAsync(process, requestedMask); + + Task ApplyAsync(ProcessModel process, CpuSelection selection); + + Task ApplyAsync(ProcessModel process, CpuSelection selection, CpuAssignmentMode mode) => + this.ApplyAsync(process, selection); } internal sealed class CpuSelectionAffinityApplier @@ -421,7 +439,7 @@ public AffinityApplyService( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public Task ApplyAsync(ProcessModel process, CpuSelection selection) => + public Task ApplyAsync(ProcessModel process, CpuSelection selection) => process == null ? Task.FromResult(AffinityApplyResult.Failed( AffinityApplyErrorCodes.ProcessExited, @@ -435,7 +453,254 @@ public Task ApplyAsync(ProcessModel process, CpuSelection s "CpuSelection is null.", isInvalidTopology: true, failureReason: AffinityApplyFailureReason.InvalidMask)) - : this.processService.SetProcessorAffinity(process, selection); + : this.processService.SetProcessorAffinity(process, selection); + + public Task ApplyAsync(ProcessModel process, CpuSelection selection, CpuAssignmentMode mode) + { + if (!Enum.IsDefined(mode)) + { + mode = CpuAssignmentMode.Automatic; + } + + if (mode == CpuAssignmentMode.Automatic) + { + return this.ApplyAutomaticAsync(process, selection); + } + + if (process == null || process.ProcessId <= 0) + { + return Task.FromResult(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessExited, + ProcessOperationUserMessages.ProcessExited, + "ProcessModel is null or no longer running.", + failureReason: AffinityApplyFailureReason.ProcessTerminated) with + { + RequestedMode = mode, + }); + } + + if (selection == null || (selection.CpuSetIds.Count == 0 && selection.LogicalProcessors.Count == 0)) + { + return Task.FromResult(AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + "CpuSelection is empty.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask) with + { + RequestedMode = mode, + }); + } + + return mode switch + { + CpuAssignmentMode.CpuSets => Task.FromResult(this.ApplyCpuSetsOnly(process, selection)), + CpuAssignmentMode.IdealProcessor => Task.FromResult(this.ApplyIdealProcessors(process, selection)), + CpuAssignmentMode.AffinityMask => this.ApplyHardAffinityAsync(process, selection), + _ => this.ApplyAutomaticAsync(process, selection), + }; + } + + public Task ApplyAsync(ProcessModel process, long requestedMask, CpuAssignmentMode mode) + { + if (mode == CpuAssignmentMode.Automatic) + { + return this.ApplyAsync(process, requestedMask); + } + + return mode == CpuAssignmentMode.AffinityMask + ? this.ApplyClassicAffinityOnlyAsync(process, requestedMask) + : Task.FromResult(InvalidThreadSelection(mode)); + } + + private async Task ApplyAutomaticAsync(ProcessModel process, CpuSelection selection) + { + var result = await this.ApplyAsync(process, selection).ConfigureAwait(false); + return result with { RequestedMode = CpuAssignmentMode.Automatic }; + } + + private AffinityApplyResult ApplyCpuSetsOnly(ProcessModel process, CpuSelection selection) + { + if (HasConflictingHardAffinity(process, selection)) + { + return RestartRequired(CpuAssignmentMode.CpuSets); + } + + using var handler = new ProcessCpuSetHandler((uint)process.ProcessId, process.Name, this.logger); + var result = handler.ApplyCpuSelectionDetailed(selection); + return result.Success + ? AffinityApplyResult.SucceededWithCpuSets(result.TechnicalMessage) with + { + RequestedMode = CpuAssignmentMode.CpuSets, + } + : AffinityApplyResult.Failed( + result.ErrorCode, + result.UserMessage, + result.TechnicalMessage, + result.IsAccessDenied, + result.IsAntiCheatLikely, + result.ErrorCode == AffinityApplyErrorCodes.InvalidTopology, + failureReason: result.IsAccessDenied ? AffinityApplyFailureReason.AccessDenied : AffinityApplyFailureReason.ApplyFailed) with + { + RequestedMode = CpuAssignmentMode.CpuSets, + EffectiveMode = CpuAssignmentMode.CpuSets, + }; + } + + private AffinityApplyResult ApplyIdealProcessors(ProcessModel process, CpuSelection selection) + { + if (selection.LogicalProcessors.Count == 0) + { + return InvalidThreadSelection(CpuAssignmentMode.IdealProcessor); + } + + if (HasConflictingHardAffinity(process, selection)) + { + return RestartRequired(CpuAssignmentMode.IdealProcessor); + } + + var result = new ProcessThreadCpuAssignmentHandler(process.ProcessId) + .ApplyIdealProcessors(selection.LogicalProcessors); + return FromThreadResult(result, CpuAssignmentMode.IdealProcessor); + } + + private Task ApplyHardAffinityAsync(ProcessModel process, CpuSelection selection) + { + if (selection.LogicalProcessors.Count == 0) + { + return Task.FromResult(InvalidThreadSelection(CpuAssignmentMode.AffinityMask)); + } + + var legacyMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + if (legacyMask is > 0 && selection.LogicalProcessors.All(processor => processor.Group == 0)) + { + return this.ApplyClassicAffinityOnlyAsync(process, legacyMask.Value); + } + + var result = new ProcessThreadCpuAssignmentHandler(process.ProcessId) + .ApplyGroupAffinity(selection.LogicalProcessors); + return Task.FromResult(FromThreadResult(result, CpuAssignmentMode.AffinityMask)); + } + + private async Task ApplyClassicAffinityOnlyAsync(ProcessModel process, long requestedMask) + { + try + { + var observed = await Task.Run(() => + { + using var target = Process.GetProcessById(process.ProcessId); + target.ProcessorAffinity = new IntPtr(requestedMask); + return (long)target.ProcessorAffinity; + }).ConfigureAwait(false); + process.ProcessorAffinity = observed; + return observed == requestedMask + ? AffinityApplyResult.Succeeded(requestedMask, observed) with + { + RequestedMode = CpuAssignmentMode.AffinityMask, + UsedLegacyAffinity = true, + } + : AffinityApplyResult.Failed( + requestedMask, + observed, + AffinityApplyFailureReason.VerificationMismatch, + $"Windows reported affinity 0x{observed:X} after requesting 0x{requestedMask:X}.") with + { + RequestedMode = CpuAssignmentMode.AffinityMask, + EffectiveMode = CpuAssignmentMode.AffinityMask, + }; + } + catch (Exception ex) when (AffinityApplyExceptionClassifier.IsAccessDenied(ex)) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.AccessDenied, + ProcessOperationUserMessages.AccessDenied, + ex.Message, + isAccessDenied: true, + requestedMask: requestedMask, + failureReason: AffinityApplyFailureReason.AccessDenied) with + { + RequestedMode = CpuAssignmentMode.AffinityMask, + EffectiveMode = CpuAssignmentMode.AffinityMask, + }; + } + catch (Exception ex) + { + return AffinityApplyResult.Failed( + AffinityApplyErrorCodes.NativeApplyFailed, + ProcessOperationUserMessages.ProcessExited, + ex.Message, + requestedMask: requestedMask) with + { + RequestedMode = CpuAssignmentMode.AffinityMask, + EffectiveMode = CpuAssignmentMode.AffinityMask, + }; + } + } + + private static AffinityApplyResult FromThreadResult(ThreadCpuAssignmentResult result, CpuAssignmentMode mode) => + result.Success + ? new AffinityApplyResult + { + Success = true, + UserMessage = "CPU assignment applied successfully.", + TechnicalMessage = $"{mode} applied and verified for {result.AppliedThreadCount} thread(s).", + RequestedMode = mode, + EffectiveMode = mode, + AppliedThreadCount = result.AppliedThreadCount, + UsedIdealProcessor = mode == CpuAssignmentMode.IdealProcessor, + UsedLegacyAffinity = false, + } + : AffinityApplyResult.Failed( + result.IsAccessDenied ? AffinityApplyErrorCodes.AccessDenied : AffinityApplyErrorCodes.NativeApplyFailed, + result.IsAccessDenied ? ProcessOperationUserMessages.AccessDenied : "ThreadPilot could not apply the CPU assignment to every live thread.", + result.Error, + isAccessDenied: result.IsAccessDenied, + failureReason: result.IsAccessDenied ? AffinityApplyFailureReason.AccessDenied : AffinityApplyFailureReason.ApplyFailed) with + { + RequestedMode = mode, + EffectiveMode = mode, + AppliedThreadCount = result.AppliedThreadCount, + UsedIdealProcessor = mode == CpuAssignmentMode.IdealProcessor, + UsedLegacyAffinity = false, + }; + + private static bool HasConflictingHardAffinity(ProcessModel process, CpuSelection selection) + { + if (process.ProcessorAffinity <= 0 || selection.LogicalProcessors.Count == 0) + { + return false; + } + + if (selection.LogicalProcessors.Any(processor => processor.Group != 0)) + { + return true; + } + + var desiredMask = CpuSelection.ToLegacyAffinityMaskOrNull(selection); + return desiredMask.HasValue && (desiredMask.Value & ~process.ProcessorAffinity) != 0; + } + + private static AffinityApplyResult RestartRequired(CpuAssignmentMode mode) => + AffinityApplyResult.Failed( + AffinityApplyErrorCodes.ProcessRestartRequired, + ProcessOperationUserMessages.ProcessRestartRequired, + "An existing hard affinity conflicts with the selected soft CPU assignment.") with + { + RequestedMode = mode, + EffectiveMode = mode, + }; + + private static AffinityApplyResult InvalidThreadSelection(CpuAssignmentMode mode) => + AffinityApplyResult.Failed( + AffinityApplyErrorCodes.InvalidSelection, + ProcessOperationUserMessages.InvalidTopology, + $"{mode} requires logical processor coordinates.", + isInvalidTopology: true, + failureReason: AffinityApplyFailureReason.InvalidMask) with + { + RequestedMode = mode, + EffectiveMode = mode, + }; public async Task ApplyAsync(ProcessModel process, long requestedMask) { diff --git a/Services/ApplicationSettingsService.cs b/Services/ApplicationSettingsService.cs index 5a3fd8d..094725d 100644 --- a/Services/ApplicationSettingsService.cs +++ b/Services/ApplicationSettingsService.cs @@ -270,9 +270,14 @@ public void ValidateAndFixSettings() } } - this.settings.Language = LocalizationService.ResolveLanguagePreference( - this.settings.Language, - this.systemUiCultureProvider()); + this.settings.Language = LocalizationService.ResolveLanguagePreference( + this.settings.Language, + this.systemUiCultureProvider()); + + if (!Enum.IsDefined(this.settings.DefaultCpuAssignmentMode)) + { + this.settings.DefaultCpuAssignmentMode = CpuAssignmentMode.Automatic; + } } public async Task ExportSettingsAsync(string filePath) diff --git a/Services/NotificationService.cs b/Services/NotificationService.cs index 4bfc5e2..5f67771 100644 --- a/Services/NotificationService.cs +++ b/Services/NotificationService.cs @@ -416,11 +416,16 @@ private string TryGetLocalizedNotificationString(string input) "Game Boost Activated" => "Notification_GameBoostActivatedTitle", "Game Boost Deactivated" => "Notification_GameBoostDeactivatedTitle", "Process Monitor Error" => "Notification_ProcessMonitorErrorTitle", - "Affinity blocked" => "Notification_AffinityBlockedTitle", - "Affinity applied" => "Notification_AffinityAppliedTitle", - "Affinity adjusted" => "Notification_AffinityAdjustedTitle", - "Affinity failed" => "Notification_AffinityFailedTitle", - "Affinity error" => "Notification_AffinityErrorTitle", + "Affinity blocked" => "Notification_AffinityBlockedTitle", + "CPU assignment blocked" => "Notification_AffinityBlockedTitle", + "Affinity applied" => "Notification_AffinityAppliedTitle", + "CPU assignment applied" => "Notification_AffinityAppliedTitle", + "Affinity adjusted" => "Notification_AffinityAdjustedTitle", + "CPU assignment adjusted" => "Notification_AffinityAdjustedTitle", + "Affinity failed" => "Notification_AffinityFailedTitle", + "CPU assignment failed" => "Notification_AffinityFailedTitle", + "Affinity error" => "Notification_AffinityErrorTitle", + "CPU assignment error" => "Notification_AffinityErrorTitle", "Priority blocked" => "Notification_PriorityBlockedTitle", "Priority warning" => "Notification_PriorityWarningTitle", "Priority applied" => "Notification_PriorityAppliedTitle", diff --git a/Services/PersistentRulesEngine.cs b/Services/PersistentRulesEngine.cs index 45f822e..849bccf 100644 --- a/Services/PersistentRulesEngine.cs +++ b/Services/PersistentRulesEngine.cs @@ -291,14 +291,18 @@ private async Task VerifyPriorityAsync( private Task ApplyAffinityAsync(PersistentProcessRule rule, ProcessModel process) { - if (rule.CpuSelection != null) - { - return this.affinityApplyService.ApplyAsync(process, rule.CpuSelection); - } + if (rule.CpuSelection != null) + { + return rule.CpuAssignmentMode == CpuAssignmentMode.Automatic + ? this.affinityApplyService.ApplyAsync(process, rule.CpuSelection) + : this.affinityApplyService.ApplyAsync(process, rule.CpuSelection, rule.CpuAssignmentMode); + } if (rule.LegacyAffinityMask.HasValue) { - return this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value); + return rule.CpuAssignmentMode == CpuAssignmentMode.Automatic + ? this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value) + : this.affinityApplyService.ApplyAsync(process, rule.LegacyAffinityMask.Value, rule.CpuAssignmentMode); } return Task.FromResult(AffinityApplyResult.Succeeded(0, process.ProcessorAffinity)); diff --git a/Services/ProcessAffinityApplyCoordinator.cs b/Services/ProcessAffinityApplyCoordinator.cs index 3d58dfd..544644a 100644 --- a/Services/ProcessAffinityApplyCoordinator.cs +++ b/Services/ProcessAffinityApplyCoordinator.cs @@ -13,11 +13,19 @@ Task ApplyCoreMaskAsync( CoreMask coreMask, CancellationToken cancellationToken = default); - Task ApplyCoreSelectionAsync( - ProcessModel process, - IReadOnlyList boolMask, - string selectionReason, - CancellationToken cancellationToken = default); + Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default); + + Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CpuAssignmentMode mode, + CancellationToken cancellationToken = default) => + this.ApplyCoreSelectionAsync(process, boolMask, selectionReason, cancellationToken); } public sealed class ProcessAffinityApplyCoordinator : IProcessAffinityApplyCoordinator @@ -58,12 +66,27 @@ public Task ApplyCoreMaskAsync( cancellationToken); } - public async Task ApplyCoreSelectionAsync( - ProcessModel process, - IReadOnlyList boolMask, - string selectionReason, - CancellationToken cancellationToken = default) - { + public async Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CancellationToken cancellationToken = default) + { + return await this.ApplyCoreSelectionAsync( + process, + boolMask, + selectionReason, + CpuAssignmentMode.Automatic, + cancellationToken).ConfigureAwait(false); + } + + public async Task ApplyCoreSelectionAsync( + ProcessModel process, + IReadOnlyList boolMask, + string selectionReason, + CpuAssignmentMode mode, + CancellationToken cancellationToken = default) + { ArgumentNullException.ThrowIfNull(process); ArgumentNullException.ThrowIfNull(boolMask); @@ -83,7 +106,7 @@ public async Task ApplyCoreSelectionAsync( cancellationToken).ConfigureAwait(false); if (migratedSelection != null) { - return await this.affinityApplyService.ApplyAsync(process, migratedSelection).ConfigureAwait(false); + return await this.affinityApplyService.ApplyAsync(process, migratedSelection, mode).ConfigureAwait(false); } if (!TryBuildSafeLegacyMask(boolMask, out var legacyMask, out var legacyFailure)) @@ -91,7 +114,20 @@ public async Task ApplyCoreSelectionAsync( return legacyFailure; } - return await this.affinityApplyService.ApplyAsync(process, legacyMask).ConfigureAwait(false); + if (mode == CpuAssignmentMode.Automatic) + { + return await this.affinityApplyService.ApplyAsync(process, legacyMask).ConfigureAwait(false); + } + + var processors = boolMask + .Select((selected, index) => (selected, index)) + .Where(item => item.selected) + .Select(item => new ProcessorRef(0, (byte)item.index, item.index)) + .ToList(); + return await this.affinityApplyService.ApplyAsync( + process, + new CpuSelection { LogicalProcessors = processors }, + mode).ConfigureAwait(false); } private async Task TryMigrateToCpuSelectionAsync( diff --git a/Services/ProcessOperationUserMessages.cs b/Services/ProcessOperationUserMessages.cs index ad7f73a..0cc3f70 100644 --- a/Services/ProcessOperationUserMessages.cs +++ b/Services/ProcessOperationUserMessages.cs @@ -22,8 +22,11 @@ internal static class ProcessOperationUserMessages public const string ProcessExited = "The process exited before ThreadPilot could apply the change."; - public const string CpuSetsUnavailable = - "Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible."; + public const string CpuSetsUnavailable = + "Windows CPU Sets are unavailable or rejected this selection. ThreadPilot will use a safe fallback only when possible."; + + public const string ProcessRestartRequired = + "This process has a conflicting hard affinity. Restart it before applying a soft CPU assignment; ThreadPilot will not widen external restrictions automatically."; public const string HighPriorityWarning = "High priority can improve responsiveness for some workloads but may reduce system responsiveness."; diff --git a/Services/ProcessRuleCreationService.cs b/Services/ProcessRuleCreationService.cs index 28a8f4b..82cfd4a 100644 --- a/Services/ProcessRuleCreationService.cs +++ b/Services/ProcessRuleCreationService.cs @@ -14,11 +14,12 @@ Task SaveRuleAsync( ProcessRuleCreationPayload payload, CancellationToken cancellationToken = default); - Task SaveCurrentSettingsAsRuleAsync( - ProcessModel process, - IReadOnlyList? currentCoreSelection, - ProcessMemoryPriority? currentMemoryPriority, - CancellationToken cancellationToken = default); + Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CpuAssignmentMode cpuAssignmentMode = CpuAssignmentMode.Automatic, + CancellationToken cancellationToken = default); } public sealed record ProcessRuleCreationPayload @@ -29,7 +30,9 @@ public sealed record ProcessRuleCreationPayload public ProcessPriorityClass? Priority { get; init; } - public ProcessMemoryPriority? MemoryPriority { get; init; } + public ProcessMemoryPriority? MemoryPriority { get; init; } + + public CpuAssignmentMode CpuAssignmentMode { get; init; } = CpuAssignmentMode.Automatic; } public sealed record ProcessRuleCreationResult @@ -82,11 +85,12 @@ public ProcessRuleCreationService( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task SaveCurrentSettingsAsRuleAsync( - ProcessModel process, - IReadOnlyList? currentCoreSelection, - ProcessMemoryPriority? currentMemoryPriority, - CancellationToken cancellationToken = default) + public async Task SaveCurrentSettingsAsRuleAsync( + ProcessModel process, + IReadOnlyList? currentCoreSelection, + ProcessMemoryPriority? currentMemoryPriority, + CpuAssignmentMode cpuAssignmentMode = CpuAssignmentMode.Automatic, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(process); @@ -95,7 +99,8 @@ public async Task SaveCurrentSettingsAsRuleAsync( Priority = ShouldCaptureCurrentCpuPriority(process.Priority) ? process.Priority : null, - MemoryPriority = currentMemoryPriority, + MemoryPriority = currentMemoryPriority, + CpuAssignmentMode = cpuAssignmentMode, }; var affinityPayload = currentCoreSelection == null @@ -118,7 +123,8 @@ public async Task SaveCurrentSettingsAsRuleAsync( payload = payload with { CpuSelection = affinityPayload.Payload.CpuSelection, - LegacyAffinityMask = affinityPayload.Payload.LegacyAffinityMask, + LegacyAffinityMask = affinityPayload.Payload.LegacyAffinityMask, + CpuAssignmentMode = cpuAssignmentMode, }; } @@ -164,7 +170,8 @@ public async Task SaveRuleAsync( ProcessName = processName, ExecutablePath = executablePath, CpuSelection = payload.CpuSelection, - LegacyAffinityMask = HasSelectionPayload(payload.CpuSelection) ? null : payload.LegacyAffinityMask, + LegacyAffinityMask = HasSelectionPayload(payload.CpuSelection) ? null : payload.LegacyAffinityMask, + CpuAssignmentMode = payload.CpuAssignmentMode, Priority = payload.Priority, MemoryPriority = payload.MemoryPriority, ApplyAffinityOnStart = HasSelectionPayload(payload.CpuSelection) || payload.LegacyAffinityMask.HasValue, @@ -234,18 +241,21 @@ private static PayloadSanitizationResult SanitizePayload(ProcessRuleCreationPayl ProcessOperationUserMessages.RealtimePriorityBlocked); } - var hasCpuSelection = HasSelectionPayload(payload.CpuSelection); + var hasCpuSelection = HasSelectionPayload(payload.CpuSelection); var legacyMask = hasCpuSelection ? null : payload.LegacyAffinityMask; if (legacyMask.HasValue && legacyMask.Value == 0) { legacyMask = null; } - return PayloadSanitizationResult.Succeeded(payload with - { - CpuSelection = hasCpuSelection ? payload.CpuSelection : null, - LegacyAffinityMask = legacyMask, - }); + return PayloadSanitizationResult.Succeeded(payload with + { + CpuSelection = hasCpuSelection ? payload.CpuSelection : null, + LegacyAffinityMask = legacyMask, + CpuAssignmentMode = Enum.IsDefined(payload.CpuAssignmentMode) + ? payload.CpuAssignmentMode + : CpuAssignmentMode.Automatic, + }); } private static bool ShouldCaptureCurrentCpuPriority(ProcessPriorityClass priority) => diff --git a/Services/WindowsCpuTopologyProvider.cs b/Services/WindowsCpuTopologyProvider.cs index 6651eab..6d0c70e 100644 --- a/Services/WindowsCpuTopologyProvider.cs +++ b/Services/WindowsCpuTopologyProvider.cs @@ -92,16 +92,18 @@ private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) var numaNodeIndexes = new Dictionary(); var lastLevelCacheIndexes = new Dictionary(); var packageIndexes = new Dictionary(); - var smtSiblingGlobalIndexes = new Dictionary>(); + var smtSiblingGlobalIndexes = new Dictionary>(); + var reservedLogicalProcessorIndexes = new HashSet(); this.ReadCpuSetInformation( logicalProcessors, cpuSetIds, efficiencyClasses, coreIndexes, - numaNodeIndexes, - lastLevelCacheIndexes, - cancellationToken); + numaNodeIndexes, + lastLevelCacheIndexes, + reservedLogicalProcessorIndexes, + cancellationToken); this.ReadLogicalProcessorRelationships( logicalProcessors, @@ -155,18 +157,20 @@ private CpuTopologySnapshot CreateSnapshot(CancellationToken cancellationToken) coreIndexes, numaNodeIndexes, lastLevelCacheIndexes, - packageIndexes, - smtSiblingGlobalIndexes); + packageIndexes, + smtSiblingGlobalIndexes, + reservedLogicalProcessorIndexes); } private void ReadCpuSetInformation( HashSet logicalProcessors, IDictionary cpuSetIds, IDictionary efficiencyClasses, - IDictionary coreIndexes, - IDictionary numaNodeIndexes, - IDictionary lastLevelCacheIndexes, - CancellationToken cancellationToken) + IDictionary coreIndexes, + IDictionary numaNodeIndexes, + IDictionary lastLevelCacheIndexes, + HashSet reservedLogicalProcessorIndexes, + CancellationToken cancellationToken) { uint requiredLength = 0; if (GetSystemCpuSetInformation(IntPtr.Zero, 0, out requiredLength, IntPtr.Zero, 0)) @@ -210,7 +214,11 @@ private void ReadCpuSetInformation( efficiencyClasses[processor] = info.EfficiencyClass; coreIndexes.TryAdd(processor, info.CoreIndex); numaNodeIndexes[processor] = info.NumaNodeIndex; - lastLevelCacheIndexes[processor] = info.LastLevelCacheIndex; + lastLevelCacheIndexes[processor] = info.LastLevelCacheIndex; + if ((info.AllFlags & (1 << 1)) != 0) + { + reservedLogicalProcessorIndexes.Add(processor.GlobalIndex); + } } offset += (int)info.Size; diff --git a/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs index 22ac49f..006b88f 100644 --- a/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs @@ -22,7 +22,8 @@ public sealed class ActivityAuditServiceTests [InlineData("CpuSetsCleared", "CPU Sets cleared for Game.exe", "Affinity", ActivityAuditSeverity.Success)] [InlineData("CpuSetsClearFailed", "The process exited before ThreadPilot could apply the change.", "Affinity", ActivityAuditSeverity.Error)] [InlineData("ProcessAffinityApplied", "Affinity applied successfully to Game.exe", "Affinity", ActivityAuditSeverity.Success)] - [InlineData("ProcessAffinityFailed", "The process appears protected by anti-cheat or process protection.", "Affinity", ActivityAuditSeverity.Warning)] + [InlineData("ProcessAffinityFailed", "The process appears protected by anti-cheat or process protection.", "Affinity", ActivityAuditSeverity.Warning)] + [InlineData("ProcessCpuAssignmentApplied", "CPU assignment applied with IdealProcessor", "Affinity", ActivityAuditSeverity.Success)] [InlineData("PersistentRuleSaved", "Saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] [InlineData("PersistentRuleSaveFailed", "Failed to save rule for Game.exe.", "Rules", ActivityAuditSeverity.Error)] [InlineData("PersistentRuleAutoApplied", "Auto-applied saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs index 615411e..818052c 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsModelTests.cs @@ -17,7 +17,8 @@ public void Constructor_StartMinimizedDefaultsFalse_ForManualLaunchVisibility() Assert.Equal("en-US", settings.Language); Assert.True(settings.EnableAutomaticUpdateChecks); Assert.False(settings.IncludePrereleaseUpdates); - Assert.Null(settings.LastUpdateCheckUtc); + Assert.Null(settings.LastUpdateCheckUtc); + Assert.Equal(CpuAssignmentMode.Automatic, settings.DefaultCpuAssignmentMode); } [Fact] @@ -25,15 +26,17 @@ public void CopyFrom_CopiesLanguage() { var source = new ApplicationSettingsModel { - Language = "zh-CN", - EnableAutomationMonitoring = false, + Language = "zh-CN", + EnableAutomationMonitoring = false, + DefaultCpuAssignmentMode = CpuAssignmentMode.IdealProcessor, }; var target = new ApplicationSettingsModel(); target.CopyFrom(source); Assert.Equal("zh-CN", target.Language); - Assert.False(target.EnableAutomationMonitoring); + Assert.False(target.EnableAutomationMonitoring); + Assert.Equal(CpuAssignmentMode.IdealProcessor, target.DefaultCpuAssignmentMode); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs index 974c551..b369a4a 100644 --- a/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ApplicationSettingsServiceTests.cs @@ -31,7 +31,7 @@ public async Task LoadSettingsAsync_CreatesDefaults_WhenFileIsMissing() } [Fact] - public async Task LoadSettingsAsync_DefaultsAutomationMonitoringEnabled_ForOlderSettingsJson() + public async Task LoadSettingsAsync_DefaultsAutomationMonitoringEnabled_ForOlderSettingsJson() { var storage = new FakeSettingsStorage(); storage.Files[TestPaths.SettingsFilePath] = "{}"; @@ -39,8 +39,25 @@ public async Task LoadSettingsAsync_DefaultsAutomationMonitoringEnabled_ForOlder await service.LoadSettingsAsync(); - Assert.True(service.Settings.EnableAutomationMonitoring); - } + Assert.True(service.Settings.EnableAutomationMonitoring); + Assert.Equal(CpuAssignmentMode.Automatic, service.Settings.DefaultCpuAssignmentMode); + } + + [Fact] + public async Task UpdateSettingsAsync_RoundTripsDefaultCpuAssignmentMode() + { + var storage = new FakeSettingsStorage(); + var service = CreateService(storage); + await service.LoadSettingsAsync(); + var settings = service.Settings; + settings.DefaultCpuAssignmentMode = CpuAssignmentMode.CpuSets; + + await service.UpdateSettingsAsync(settings); + + var reloaded = CreateService(storage); + await reloaded.LoadSettingsAsync(); + Assert.Equal(CpuAssignmentMode.CpuSets, reloaded.Settings.DefaultCpuAssignmentMode); + } [Fact] public async Task UpdateSettingsAsync_PersistsAutomationMonitoringDisabled() diff --git a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs index 42a85ad..37f96d9 100644 --- a/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PackagingMetadataTests.cs @@ -4,8 +4,8 @@ namespace ThreadPilot.Core.Tests public sealed partial class PackagingMetadataTests { - private const string ReleaseVersion = "1.5.3"; - private const string ReleaseAssemblyVersion = "1.5.3.0"; + private const string ReleaseVersion = "1.6.0"; + private const string ReleaseAssemblyVersion = "1.6.0.0"; [Fact] public void InnoInstallers_UseStableDisplayNameAndSeparateVersionMetadata() @@ -99,7 +99,7 @@ private static string FindRepositoryRoot() throw new InvalidOperationException("Repository root could not be located."); } - [GeneratedRegex("#define MyAppVersion \"1\\.5\\.3\"", RegexOptions.CultureInvariant)] + [GeneratedRegex("#define MyAppVersion \"1\\.6\\.0\"", RegexOptions.CultureInvariant)] private static partial Regex MyAppVersionRegex(); } } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs index 95bf867..3ce5313 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentProcessRuleJsonStoreTests.cs @@ -36,7 +36,8 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() LogicalProcessors = [new ProcessorRef(0, 0, 0)], GlobalLogicalProcessorIndexes = [0], }, - LegacyAffinityMask = 3, + LegacyAffinityMask = 3, + CpuAssignmentMode = CpuAssignmentMode.IdealProcessor, Priority = ProcessPriorityClass.AboveNormal, MemoryPriority = ProcessMemoryPriority.BelowNormal, ApplyAffinityOnStart = true, @@ -55,7 +56,8 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() var loadedRule = Assert.Single(loaded); Assert.Equal("rule-a", loadedRule.Id); - Assert.Equal(3, loadedRule.LegacyAffinityMask); + Assert.Equal(3, loadedRule.LegacyAffinityMask); + Assert.Equal(CpuAssignmentMode.IdealProcessor, loadedRule.CpuAssignmentMode); Assert.Equal(ProcessPriorityClass.AboveNormal, loadedRule.Priority); Assert.Equal(ProcessMemoryPriority.BelowNormal, loadedRule.MemoryPriority); Assert.True(loadedRule.ApplyMemoryPriorityOnStart); @@ -66,7 +68,30 @@ public async Task SaveAndLoadAsync_RoundTripsCpuSelectionAndLegacyAffinityMask() { DeleteFile(filePath); } - } + } + + [Fact] + public async Task LoadAsync_OldRuleWithoutCpuAssignmentMode_DefaultsToAutomatic() + { + var filePath = CreateTemporaryFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllTextAsync( + filePath, + """ + [{"Id":"legacy","Name":"Legacy","IsEnabled":true,"ProcessName":"game.exe","LegacyAffinityMask":3,"ApplyAffinityOnStart":true}] + """); + + try + { + var rule = Assert.Single(await new PersistentProcessRuleJsonStore(() => filePath).LoadAsync()); + Assert.Equal(CpuAssignmentMode.Automatic, rule.CpuAssignmentMode); + Assert.Equal(3, rule.LegacyAffinityMask); + } + finally + { + DeleteFile(filePath); + } + } [Fact] public async Task MultipleRules_SaveThenReload_DoesNotLoseEntries() diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 6f5ef16..9c1123a 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -30,7 +30,7 @@ public async Task ApplyMatchingRulesAsync_WithCpuSelection_AppliesCpuSelection() } [Fact] - public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAffinity() + public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAffinity() { var rule = CreateRule(legacyAffinityMask: 3, applyAffinity: true); var affinity = CreateAffinityService(); @@ -44,8 +44,39 @@ public async Task ApplyMatchingRulesAsync_WithLegacyAffinityMask_AppliesLegacyAf Assert.True(results[0].Success); Assert.True(results[0].AffinityApplied); affinity.Verify(s => s.ApplyAsync(process, 3), Times.Once); - affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); - } + affinity.Verify(s => s.ApplyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(CpuAssignmentMode.AffinityMask)] + [InlineData(CpuAssignmentMode.IdealProcessor)] + [InlineData(CpuAssignmentMode.CpuSets)] + public async Task ApplyMatchingRulesAsync_WithExplicitMode_DispatchesThatModeWithoutAutomaticPath( + CpuAssignmentMode mode) + { + var selection = CreateCpuSelection(); + var rule = CreateRule(cpuSelection: selection, cpuAssignmentMode: mode, applyAffinity: true); + var affinity = new Mock(MockBehavior.Strict); + affinity + .Setup(service => service.ApplyAsync(It.IsAny(), selection, mode)) + .ReturnsAsync(AffinityApplyResult.Succeeded(1, 1) with + { + RequestedMode = mode, + EffectiveMode = mode, + }); + var engine = CreateEngine( + [rule], + affinity.Object, + CreateProcessService().Object, + CreateMemoryPriorityService().Object); + var process = CreateProcess(); + + var result = Assert.Single(await engine.ApplyMatchingRulesAsync(process)); + + Assert.True(result.Success); + affinity.Verify(service => service.ApplyAsync(process, selection, mode), Times.Once); + affinity.Verify(service => service.ApplyAsync(process, selection), Times.Never); + } [Fact] public async Task ApplyPriority_ObservedPriorityMatchesRequested_ReturnsVerifiedSuccess() @@ -508,9 +539,10 @@ private static PersistentProcessRule CreateRule( string id = "rule", CpuSelection? cpuSelection = null, long? legacyAffinityMask = null, - ProcessPriorityClass? priority = null, - ProcessMemoryPriority? memoryPriority = null, - bool applyAffinity = false, + ProcessPriorityClass? priority = null, + ProcessMemoryPriority? memoryPriority = null, + CpuAssignmentMode cpuAssignmentMode = CpuAssignmentMode.Automatic, + bool applyAffinity = false, bool applyPriority = false, bool applyMemoryPriority = false) => new() @@ -522,7 +554,8 @@ private static PersistentProcessRule CreateRule( CpuSelection = cpuSelection, LegacyAffinityMask = legacyAffinityMask, Priority = priority, - MemoryPriority = memoryPriority, + MemoryPriority = memoryPriority, + CpuAssignmentMode = cpuAssignmentMode, ApplyAffinityOnStart = applyAffinity, ApplyPriorityOnStart = applyPriority, ApplyMemoryPriorityOnStart = applyMemoryPriority, diff --git a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs index 752f981..726f16f 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessCpuSetHandlerTests.cs @@ -1,6 +1,7 @@ namespace ThreadPilot.Core.Tests { - using System; + using System; + using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; using ThreadPilot.Models; using ThreadPilot.Platforms.Windows; @@ -256,7 +257,7 @@ [new ProcessorRef(1, 0, 64)] = 200, } [Fact] - public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacyCpu0BitDoesNotRepresentGroup1Cpu0() + public void ProcessCpuSetHandler_ApplyCpuSetMask_LegacyCpu0BitDoesNotRepresentGroup1Cpu0() { var nativeApi = new FakeProcessCpuSetNativeApi(); using var handler = CreateHandler( @@ -270,8 +271,57 @@ [new ProcessorRef(1, 0, 64)] = 200, var result = handler.ApplyCpuSetMask(0b1); Assert.True(result); - Assert.Equal([100U], nativeApi.LastAppliedCpuSetIds!); - } + Assert.Equal([100U], nativeApi.LastAppliedCpuSetIds!); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_BlocksCpuSetAllocatedToAnotherProcess() + { + var nativeApi = new FakeProcessCpuSetNativeApi + { + SystemCpuSets = + [ + new SYSTEM_CPU_SET_INFORMATION { Id = 400, AllFlags = 1 << 1 }, + ], + }; + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelectionDetailed(new CpuSelection { CpuSetIds = [400] }); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.CpuSetsUnavailable, result.ErrorCode); + Assert.False(nativeApi.WasSetProcessDefaultCpuSetsCalled); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_AllowsCpuSetAllocatedToTargetProcess() + { + var nativeApi = new FakeProcessCpuSetNativeApi + { + SystemCpuSets = + [ + new SYSTEM_CPU_SET_INFORMATION { Id = 400, AllFlags = (1 << 1) | (1 << 2) }, + ], + }; + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelectionDetailed(new CpuSelection { CpuSetIds = [400] }); + + Assert.True(result.Success); + Assert.True(nativeApi.WasSetProcessDefaultCpuSetsCalled); + } + + [Fact] + public void ProcessCpuSetHandler_ApplyCpuSelection_FailsWhenWindowsDoesNotVerifySelection() + { + var nativeApi = new FakeProcessCpuSetNativeApi { VerifiedCpuSetIds = [401] }; + using var handler = CreateHandler(nativeApi, CpuSetMapping.Empty); + + var result = handler.ApplyCpuSelectionDetailed(new CpuSelection { CpuSetIds = [400] }); + + Assert.False(result.Success); + Assert.Equal(AffinityApplyErrorCodes.NativeApplyFailed, result.ErrorCode); + } private static ProcessCpuSetHandler CreateHandler( FakeProcessCpuSetNativeApi nativeApi, @@ -290,20 +340,41 @@ private sealed class FakeProcessCpuSetNativeApi : IProcessCpuSetNativeApi public int LastWin32Error { get; set; } - public bool SetProcessDefaultCpuSetsResult { get; init; } = true; + public bool SetProcessDefaultCpuSetsResult { get; init; } = true; + + public uint[]? VerifiedCpuSetIds { get; init; } + + public IReadOnlyList SystemCpuSets { get; init; } = []; public SafeProcessHandle OpenProcess(ProcessAccessFlags access, bool inheritHandle, uint processId) { return new SafeProcessHandle(new IntPtr(1), ownsHandle: false); } - public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) + public bool SetProcessDefaultCpuSets(SafeProcessHandle process, uint[]? cpuSetIds, uint cpuSetIdCount) { this.WasSetProcessDefaultCpuSetsCalled = true; this.LastAppliedCpuSetIds = cpuSetIds; this.LastAppliedCpuSetCount = cpuSetIdCount; - return this.SetProcessDefaultCpuSetsResult; - } + return this.SetProcessDefaultCpuSetsResult; + } + + public bool GetProcessDefaultCpuSets( + SafeProcessHandle process, + uint[]? cpuSetIds, + uint cpuSetIdCount, + out uint requiredIdCount) + { + var verified = this.VerifiedCpuSetIds ?? this.LastAppliedCpuSetIds ?? []; + requiredIdCount = (uint)verified.Length; + if (cpuSetIds == null || cpuSetIdCount < requiredIdCount) + { + return requiredIdCount == 0; + } + + Array.Copy(verified, cpuSetIds, requiredIdCount); + return true; + } public bool GetProcessTimes( SafeProcessHandle process, @@ -326,8 +397,30 @@ public bool GetSystemCpuSetInformation( SafeProcessHandle process, uint flags) { - returnedLength = 0; - return false; + if (this.SystemCpuSets.Count == 0) + { + returnedLength = 0; + return false; + } + + var itemSize = Marshal.SizeOf(); + returnedLength = (uint)(itemSize * this.SystemCpuSets.Count); + if (information == IntPtr.Zero || bufferLength < returnedLength) + { + this.LastWin32Error = 0x7A; + return false; + } + + for (var index = 0; index < this.SystemCpuSets.Count; index++) + { + var item = this.SystemCpuSets[index]; + item.Size = (uint)itemSize; + item.Type = CPU_SET_INFORMATION_TYPE.CpuSetInformation; + Marshal.StructureToPtr(item, IntPtr.Add(information, index * itemSize), false); + } + + this.LastWin32Error = 0; + return true; } public int GetLastWin32Error() diff --git a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs index b109cfe..b235d22 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessRuleCreationServiceTests.cs @@ -134,15 +134,17 @@ public async Task SaveCurrentSettingsAsRuleAsync_PrefersCpuSelectionWhenTopology ])); var service = CreateService(store, topologyProvider); - await service.SaveCurrentSettingsAsRuleAsync( - CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), - currentCoreSelection: [true, false], - currentMemoryPriority: null); + await service.SaveCurrentSettingsAsRuleAsync( + CreateProcess(priority: ProcessPriorityClass.RealTime, affinity: 0), + currentCoreSelection: [true, false], + currentMemoryPriority: null, + cpuAssignmentMode: CpuAssignmentMode.IdealProcessor); var rule = Assert.Single(store.SavedRules); Assert.NotNull(rule.CpuSelection); Assert.Null(rule.LegacyAffinityMask); - Assert.True(rule.ApplyAffinityOnStart); + Assert.True(rule.ApplyAffinityOnStart); + Assert.Equal(CpuAssignmentMode.IdealProcessor, rule.CpuAssignmentMode); Assert.Equal(0, rule.CpuSelection.GlobalLogicalProcessorIndexes.Single()); } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessThreadCpuAssignmentHandlerTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessThreadCpuAssignmentHandlerTests.cs new file mode 100644 index 0000000..58e465f --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessThreadCpuAssignmentHandlerTests.cs @@ -0,0 +1,58 @@ +namespace ThreadPilot.Core.Tests +{ + using ThreadPilot.Models; + using ThreadPilot.Platforms.Windows; + + public sealed class ProcessThreadCpuAssignmentHandlerTests + { + [Fact] + public void BuildGroupAffinities_PreservesProcessorGroupsWithoutCpu64Aliasing() + { + var groups = ProcessThreadCpuAssignmentHandler.BuildGroupAffinities( + [ + new ProcessorRef(0, 0, 0), + new ProcessorRef(0, 63, 63), + new ProcessorRef(1, 0, 64), + new ProcessorRef(1, 2, 66), + ]); + + Assert.Equal(2, groups.Count); + Assert.Equal((ushort)0, groups[0].Group); + Assert.Equal((1UL << 63) | 1UL, (ulong)groups[0].Mask); + Assert.Equal((ushort)1, groups[1].Group); + Assert.Equal((nuint)5, groups[1].Mask); + } + + [Fact] + public void BuildIdealProcessorSequence_IsSortedDistinctAndStableRoundRobin() + { + var sequence = ProcessThreadCpuAssignmentHandler.BuildIdealProcessorSequence( + [ + new ProcessorRef(1, 2, 66), + new ProcessorRef(0, 3, 3), + new ProcessorRef(0, 1, 1), + new ProcessorRef(0, 1, 1), + ], + threadCount: 7); + + Assert.Equal( + [ + new ProcessorRef(0, 1, 1), + new ProcessorRef(0, 3, 3), + new ProcessorRef(1, 2, 66), + new ProcessorRef(0, 1, 1), + new ProcessorRef(0, 3, 3), + new ProcessorRef(1, 2, 66), + new ProcessorRef(0, 1, 1), + ], + sequence); + } + + [Fact] + public void Builders_ReturnEmptyForEmptySelections() + { + Assert.Empty(ProcessThreadCpuAssignmentHandler.BuildGroupAffinities([])); + Assert.Empty(ProcessThreadCpuAssignmentHandler.BuildIdealProcessorSequence([], 4)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs index 1d8bb25..2f89e81 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelAffinityTests.cs @@ -58,7 +58,7 @@ public async Task SelectingCoreMask_ReportsPendingAffinityWithoutChangingCurrent 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); + Assert.Equal("CPU selection staged. Use Apply CPU Assignment to apply it.", viewModel.AffinityEditStateText); } [Fact] diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 16b6d31..26b677b 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -64,14 +64,15 @@ public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() coordinator.Verify( service => service.ApplyCoreSelectionAsync( - rowProcess, - It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), - "Manual Process tab context menu CPU selection", - default), + rowProcess, + It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), + "Manual Process tab context menu CPU selection", + CpuAssignmentMode.Automatic, + default), Times.Once); enhancedLoggingService.Verify( service => service.LogUserActionAsync( - "ProcessAffinityApplied", + "ProcessCpuAssignmentApplied", It.IsAny(), It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 100"))), Times.Once); @@ -102,17 +103,19 @@ public async Task ApplyContextAffinityCommand_WhenRowProcessDiffersFromSelectedP coordinator.Verify( service => service.ApplyCoreSelectionAsync( - rowProcess, - It.IsAny>(), - "Manual Process tab context menu CPU selection", - default), + rowProcess, + It.IsAny>(), + "Manual Process tab context menu CPU selection", + CpuAssignmentMode.Automatic, + default), Times.Once); coordinator.Verify( service => service.ApplyCoreSelectionAsync( - oldSelectedProcess, - It.IsAny>(), - It.IsAny(), - default), + oldSelectedProcess, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + default), Times.Never); Assert.Same(rowProcess, viewModel.SelectedProcess); } @@ -135,10 +138,11 @@ public async Task ApplyContextAffinityCommand_DoesNotCallLegacyLongDirectly() coordinator.Verify( service => service.ApplyCoreSelectionAsync( - rowProcess, - It.IsAny>(), - "Manual Process tab context menu CPU selection", - default), + rowProcess, + It.IsAny>(), + "Manual Process tab context menu CPU selection", + CpuAssignmentMode.Automatic, + default), Times.Once); processService.Verify( service => service.SetProcessorAffinity(It.IsAny(), It.IsAny()), @@ -601,10 +605,11 @@ public async Task ApplyAffinityAndSaveAsRuleCommand_AppliesAffinityBeforeSavingR coordinator.Verify( service => service.ApplyCoreSelectionAsync( - process, - It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), - "Manual Process tab context menu CPU selection", - default), + process, + It.Is>(mask => mask.Count == 2 && mask[0] && !mask[1]), + "Manual Process tab context menu CPU selection", + CpuAssignmentMode.Automatic, + default), Times.Once); var rule = Assert.Single(ruleStore.SavedRules); Assert.Equal(1, rule.LegacyAffinityMask); @@ -618,10 +623,11 @@ public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesN var coordinator = new Mock(MockBehavior.Strict); coordinator .Setup(service => service.ApplyCoreSelectionAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny(), - default)) + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + default)) .ReturnsAsync(AffinityApplyResult.Failed( AffinityApplyErrorCodes.AccessDenied, ProcessOperationUserMessages.AccessDenied, @@ -671,10 +677,11 @@ public async Task ApplyAffinityAndSaveAsRuleCommand_UsesRowProcessInsteadOfStale coordinator.Verify( service => service.ApplyCoreSelectionAsync( - rowProcess, - It.IsAny>(), - It.IsAny(), - default), + rowProcess, + It.IsAny>(), + It.IsAny(), + CpuAssignmentMode.Automatic, + default), Times.Once); var rule = Assert.Single(ruleStore.SavedRules); Assert.Equal("Row.exe", rule.ProcessName); @@ -745,10 +752,11 @@ private static Mock CreateAffinityCoordinator( var coordinator = new Mock(MockBehavior.Strict); coordinator .Setup(service => service.ApplyCoreSelectionAsync( - It.IsAny(), - It.IsAny>(), - It.IsAny(), - default)) + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + default)) .ReturnsAsync(AffinityApplyResult.Succeeded(1, 1)); return coordinator; } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index e45cf43..8dbeae7 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -35,7 +35,7 @@ public void LastOperationMessageBinding_IsDisplayOnly() } [Fact] - public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() + public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() { var editableControls = new HashSet(StringComparer.Ordinal) { @@ -61,8 +61,22 @@ public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) .ToList(); - Assert.Empty(editableSummaryBindings); - } + Assert.Empty(editableSummaryBindings); + } + + [Fact] + public void CpuAssignmentMode_IsStagedTwoWayAndHasAccessibleHelp() + { + var serialized = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace) + .ToString(SaveOptions.DisableFormatting); + + Assert.Contains("SelectedValue=\"{Binding SelectedCpuAssignmentMode, Mode=TwoWay}\"", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.AffinityMask", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.IdealProcessor", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.CpuSets", serialized, StringComparison.Ordinal); + Assert.Contains("CpuAssignmentMode.Automatic", serialized, StringComparison.Ordinal); + Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource ProcessView_CpuAssignmentModeTooltip}\"", serialized, StringComparison.Ordinal); + } [Fact] public void ProcessGridRowStyle_HighlightsSelectedRowsWithNeutralTheme() diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index eb5a91a..698d845 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -197,7 +197,7 @@ public async Task NavigationPrompt_SavePersistsPendingSettingsBeforeNavigating() } [Fact] - public void SettingsView_ExposesPersistentRuleAutoApplyToggle() + public void SettingsView_ExposesPersistentRuleAutoApplyToggle() { var settingsViewPath = Path.Combine( AppContext.BaseDirectory, @@ -214,8 +214,28 @@ public void SettingsView_ExposesPersistentRuleAutoApplyToggle() Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStart}\"", serialized, StringComparison.Ordinal); Assert.Contains("TextWrapping=\"Wrap\"", serialized, StringComparison.Ordinal); Assert.Contains("IsChecked=\"{Binding Settings.ApplyPersistentRulesOnProcessStart}\"", serialized, StringComparison.Ordinal); - Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal); - } + Assert.Contains("Text=\"{DynamicResource SettingsView_ApplyOnStartDescription}\"", serialized, StringComparison.Ordinal); + } + + [Fact] + public void SettingsView_ExposesDefaultCpuAssignmentAndReadOnlyReservedState() + { + var settingsViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "SettingsView.xaml"); + var serialized = File.ReadAllText(settingsViewPath); + + Assert.Contains("SelectedValue=\"{Binding Settings.DefaultCpuAssignmentMode}\"", serialized, StringComparison.Ordinal); + Assert.Contains("Text=\"{Binding ReservedCpuSetsStatus}\"", serialized, StringComparison.Ordinal); + Assert.Contains("AutomationProperties.HelpText=\"{DynamicResource SettingsView_ReservedCpuSetsTooltip}\"", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("ReservedCpuSetsStatus, Mode=TwoWay", serialized, StringComparison.Ordinal); + } [Fact] public void SettingsView_ExposesAllSupportedLanguageSelections() diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 59c2cad..7179973 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -16,10 +16,10 @@ link true CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.5.3 - 1.5.3.0 - 1.5.3.0 - 1.5.3 + 1.6.0 + 1.6.0.0 + 1.6.0.0 + 1.6.0 diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 658b861..64e2a8b 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -145,19 +145,24 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => } } - partial void OnSelectedProcessChanged(ProcessModel? value) - { - this.UpdateSelectedProcessSummary(value); + partial void OnSelectedProcessChanged(ProcessModel? value) + { + this.SetCpuAssignmentMode( + this.settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic, + overriddenByRule: false); + this.UpdateSelectedProcessSummary(value); if (value != null && CpuTopology != null) { this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); // Immediately fetch and display real-time process information - TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex => + TaskSafety.FireAndForget(HandleSelectedProcessChangedAsync(value), ex => { this.Logger.LogWarning(ex, "Failed while handling selected process change for {ProcessName}", value.Name); - }); + }); + TaskSafety.FireAndForget(this.ResolveCpuAssignmentModeAsync(value), ex => + this.Logger.LogDebug(ex, "Could not resolve saved CPU assignment mode for {ProcessName}", value.Name)); } else if (value == null) { @@ -167,7 +172,39 @@ partial void OnSelectedProcessChanged(ProcessModel? value) // Update system tray context menu this.systemTrayService.UpdateContextMenu(value?.Name, value != null); - } + } + + partial void OnSelectedCpuAssignmentModeChanged(CpuAssignmentMode value) + { + if (!this.suppressCpuAssignmentModeChanges && this.SelectedProcess != null) + { + this.HasPendingAffinityEdits = true; + this.cpuAssignmentModeOverriddenByRule = true; + } + } + + private async Task ResolveCpuAssignmentModeAsync(ProcessModel process) + { + if (this.persistentRuleStore == null || this.persistentRuleMatcher == null) + { + return; + } + + var rules = await this.persistentRuleStore.LoadAsync().ConfigureAwait(false); + var rule = rules.FirstOrDefault(candidate => candidate.IsEnabled && this.persistentRuleMatcher.IsMatch(candidate, process)); + if (rule != null && this.SelectedProcess?.ProcessId == process.ProcessId) + { + await InvokeOnUiAsync(() => this.SetCpuAssignmentMode(rule.CpuAssignmentMode, overriddenByRule: true)); + } + } + + private void SetCpuAssignmentMode(CpuAssignmentMode mode, bool overriddenByRule) + { + this.suppressCpuAssignmentModeChanges = true; + this.SelectedCpuAssignmentMode = Enum.IsDefined(mode) ? mode : CpuAssignmentMode.Automatic; + this.suppressCpuAssignmentModeChanges = false; + this.cpuAssignmentModeOverriddenByRule = overriddenByRule; + } private void UpdateSelectedProcessSummary(ProcessModel? process) { @@ -509,7 +546,7 @@ private void UpdateAffinityDisplayState() 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."; + this.AffinityEditStateText = "CPU selection staged. Use Apply CPU Assignment to apply it."; } [RelayCommand] @@ -776,10 +813,11 @@ private async Task SaveCurrentSettingsAsRule(ProcessModel? process) var currentCoreSelection = this.HasPendingAffinityEdits && this.CpuCores.Count > 0 ? this.GetPendingCoreSelectionMask() : null; - var result = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( - targetProcess, - currentCoreSelection, - this.SelectedProcessSummary.MemoryPriority); + var result = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + targetProcess, + currentCoreSelection, + this.SelectedProcessSummary.MemoryPriority, + this.SelectedCpuAssignmentMode); this.ApplyRuleCreationResultStatus(result); await this.LogUserActionAsync( @@ -810,23 +848,24 @@ private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) } var pendingSelection = this.GetPendingCoreSelectionMask(); - var applyResult = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - process, - pendingSelection, - "Manual Process tab context menu CPU selection"); + var applyResult = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + process, + pendingSelection, + "Manual Process tab context menu CPU selection", + this.SelectedCpuAssignmentMode); if (!applyResult.Success) { this.SetContextError(applyResult.Message); await this.LogUserActionAsync( - "ProcessAffinityFailed", - applyResult.Message, - $"Process: {process.Name}, PID: {process.ProcessId}, RequestedMask: 0x{applyResult.RequestedMask:X}"); + "ProcessCpuAssignmentFailed", + applyResult.Message, + $"Process: {process.Name}, PID: {process.ProcessId}, RequestedMode: {applyResult.RequestedMode}, EffectiveMode: {applyResult.EffectiveMode}, RequestedMask: 0x{applyResult.RequestedMask:X}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } - if (!applyResult.UsedCpuSets) + if (applyResult.EffectiveMode == CpuAssignmentMode.AffinityMask) { this.UpdateCoreSelections(process.ProcessorAffinity, true); } @@ -836,19 +875,11 @@ await this.LogUserActionAsync( this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); - var saveResult = applyResult.UsedCpuSets - ? await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( - process, - pendingSelection, - currentMemoryPriority: null) - : await this.processRuleCreationService.SaveRuleAsync( - process, - new ProcessRuleCreationPayload - { - LegacyAffinityMask = applyResult.VerifiedMask == 0 - ? applyResult.RequestedMask - : applyResult.VerifiedMask, - }); + var saveResult = await this.processRuleCreationService.SaveCurrentSettingsAsRuleAsync( + process, + pendingSelection, + currentMemoryPriority: null, + cpuAssignmentMode: this.SelectedCpuAssignmentMode); this.ApplyRuleCreationResultStatus(saveResult); await this.LogUserActionAsync( @@ -882,14 +913,15 @@ await InvokeOnUiAsync(() => this.SetStatus($"Setting affinity for {selectedProcess.Name}..."); }); - var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - selectedProcess, - pendingSelection, - selectionReason); + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + selectionReason, + this.SelectedCpuAssignmentMode); await InvokeOnUiAsync(() => { - if (!result.UsedCpuSets) + if (result.EffectiveMode == CpuAssignmentMode.AffinityMask) { this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); } @@ -901,58 +933,58 @@ await InvokeOnUiAsync(() => { 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); + this.SetStatus($"CPU assignment applied successfully to {selectedProcess.Name} using {result.EffectiveMode}.", false); + _ = this.notificationService.ShowNotificationAsync("CPU assignment applied", $"{selectedProcess.Name}: {result.EffectiveMode}", 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); + _ = this.notificationService.ShowNotificationAsync("CPU assignment adjusted", result.Message, NotificationType.Warning); } else if (result.FailureReason == AffinityApplyFailureReason.ProcessTerminated) { this.SelectedProcess = null; this.ClearProcessSelection(); this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity failed", result.Message, NotificationType.Warning); + _ = this.notificationService.ShowNotificationAsync("CPU assignment failed", result.Message, NotificationType.Warning); } else if (result.FailureReason == AffinityApplyFailureReason.AccessDenied) { this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); + _ = this.notificationService.ShowNotificationAsync("CPU assignment blocked", result.Message, NotificationType.Warning); } else if (result.IsInvalidTopology || result.IsLegacyFallbackBlocked) { this.SetCriticalStatus(result.Message); - _ = this.notificationService.ShowNotificationAsync("Affinity blocked", result.Message, NotificationType.Warning); + _ = this.notificationService.ShowNotificationAsync("CPU assignment blocked", result.Message, NotificationType.Warning); } else { this.SetStatus(result.Message, false); - _ = this.notificationService.ShowNotificationAsync("Affinity error", result.Message, NotificationType.Error); + _ = this.notificationService.ShowNotificationAsync("CPU assignment error", result.Message, NotificationType.Error); } }); await this.LogUserActionAsync( - result.Success ? "ProcessAffinityApplied" : "ProcessAffinityFailed", - result.Message, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, RequestedMask: 0x{result.RequestedMask:X}, VerifiedMask: 0x{result.VerifiedMask:X}"); + result.Success ? "ProcessCpuAssignmentApplied" : "ProcessCpuAssignmentFailed", + result.Message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, RequestedMode: {result.RequestedMode}, EffectiveMode: {result.EffectiveMode}, Threads: {result.AppliedThreadCount}, RequestedMask: 0x{result.RequestedMask:X}, VerifiedMask: 0x{result.VerifiedMask:X}"); await this.UpdateSelectedProcessSummaryAsync(selectedProcess); } catch (Exception ex) { var friendly = ex.Message; - _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); + _ = this.notificationService.ShowNotificationAsync("CPU assignment error", friendly, NotificationType.Error); await this.LogUserActionAsync( - "ProcessAffinityFailed", - friendly, - $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}"); + "ProcessCpuAssignmentFailed", + friendly, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Mode: {this.SelectedCpuAssignmentMode}"); await InvokeOnUiAsync(() => { - this.SetCriticalStatus($"Error setting affinity: {friendly}"); + this.SetCriticalStatus($"Error applying CPU assignment: {friendly}"); }); // Try to refresh process info even if setting failed, to show current state @@ -1199,7 +1231,7 @@ private async Task QuickApplyAffinityAndPowerPlan() try { - var affinityAppliedWithCpuSets = false; + var affinityModeChangedOsAffinity = false; await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { @@ -1210,15 +1242,16 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => var pendingSelection = this.GetPendingCoreSelectionMask(); if (pendingSelection.Any(selected => selected)) { - var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( - selectedProcess, - pendingSelection, - "Manual Process tab quick apply CPU selection"); + var result = await this.processAffinityApplyCoordinator.ApplyCoreSelectionAsync( + selectedProcess, + pendingSelection, + "Manual Process tab quick apply CPU selection", + this.SelectedCpuAssignmentMode); if (!result.Success) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - if (!result.UsedCpuSets) + if (result.EffectiveMode == CpuAssignmentMode.AffinityMask) { this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); } @@ -1232,7 +1265,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.HasPendingAffinityEdits = false; this.UpdateAffinityDisplayState(); - affinityAppliedWithCpuSets = result.UsedCpuSets; + affinityModeChangedOsAffinity = result.EffectiveMode == CpuAssignmentMode.AffinityMask; } // Apply power plan if selected @@ -1245,7 +1278,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - if (!affinityAppliedWithCpuSets) + if (affinityModeChangedOsAffinity) { this.UpdateCoreSelections(selectedProcess.ProcessorAffinity, true); } diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index 61646c9..e251658 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -33,7 +33,9 @@ public partial class ProcessViewModel : BaseViewModel private readonly IProcessAffinityApplyCoordinator processAffinityApplyCoordinator; private readonly IProcessMemoryPriorityService? memoryPriorityService; private readonly IProcessRuleCreationService? processRuleCreationService; - private readonly IApplicationSettingsService? settingsService; + private readonly IApplicationSettingsService? settingsService; + private readonly IPersistentProcessRuleStore? persistentRuleStore; + private readonly IPersistentProcessRuleMatcher? persistentRuleMatcher; private readonly Action clipboardSetter; private readonly Action executableLocationOpener; private System.Timers.Timer? refreshTimer; @@ -45,7 +47,9 @@ public partial class ProcessViewModel : BaseViewModel private readonly ThrottledRefreshCoordinator filterRefreshCoordinator; private bool isApplyingFilter; private bool filterRefreshPending; - private bool suppressCoreSelectionEvents; + private bool suppressCoreSelectionEvents; + private bool suppressCpuAssignmentModeChanges; + private bool cpuAssignmentModeOverriddenByRule; [ObservableProperty] private bool isAutomationMonitoringEnabled = true; @@ -101,7 +105,10 @@ public partial class ProcessViewModel : BaseViewModel private string topologyStatus = "Detecting CPU topology..."; [ObservableProperty] - private bool areAdvancedFeaturesAvailable = false; + private bool areAdvancedFeaturesAvailable = false; + + [ObservableProperty] + private CpuAssignmentMode selectedCpuAssignmentMode = CpuAssignmentMode.Automatic; [ObservableProperty] private PowerPlanModel? selectedPowerPlan; @@ -210,7 +217,10 @@ public ProcessViewModel( new CpuSelectionMigrationService(), NullLogger.Instance); this.memoryPriorityService = memoryPriorityService; - this.settingsService = settingsService; + this.settingsService = settingsService; + this.persistentRuleStore = persistentRuleStore; + this.persistentRuleMatcher = persistentRuleMatcher; + this.SelectedCpuAssignmentMode = settingsService?.Settings.DefaultCpuAssignmentMode ?? CpuAssignmentMode.Automatic; this.IsAutomationMonitoringEnabled = settingsService?.Settings.EnableAutomationMonitoring ?? true; if (settingsService != null) { @@ -255,7 +265,11 @@ public ProcessViewModel( private void OnSettingsChanged(object? sender, ApplicationSettingsChangedEventArgs e) { - this.IsAutomationMonitoringEnabled = e.NewSettings.EnableAutomationMonitoring; + this.IsAutomationMonitoringEnabled = e.NewSettings.EnableAutomationMonitoring; + if (!this.cpuAssignmentModeOverriddenByRule) + { + this.SetCpuAssignmentMode(e.NewSettings.DefaultCpuAssignmentMode, overriddenByRule: false); + } if (!this.IsAutomationMonitoringEnabled) { this.SetUiRefreshEnabled(false, refreshImmediately: false); diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 4608906..70a96d6 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -31,7 +31,8 @@ public partial class SettingsViewModel : BaseViewModel private readonly ISystemTrayService systemTrayService; private readonly IUpdateService updateService; private readonly IApplicationVersionProvider versionProvider; - private readonly ILocalizationService localizationService; + private readonly ILocalizationService localizationService; + private readonly ICpuTopologyProvider? cpuTopologyProvider; private ApplicationSettingsModel savedSettingsSnapshot; private bool isSyncingFromService = false; private bool? appliedThemePreference; @@ -87,8 +88,11 @@ public partial class SettingsViewModel : BaseViewModel [ObservableProperty] private string lastUpdateCheckText = string.Empty; - [ObservableProperty] - private bool isUpdateAvailable = false; + [ObservableProperty] + private bool isUpdateAvailable = false; + + [ObservableProperty] + private string reservedCpuSetsStatus = string.Empty; public bool CanDownloadAndInstallUpdate => this.IsUpdateAvailable && !this.IsLoading; @@ -104,9 +108,10 @@ public SettingsViewModel( ISystemTrayService systemTrayService, IUpdateService updateService, IApplicationVersionProvider versionProvider, - ILocalizationService localizationService, - IEnhancedLoggingService? enhancedLoggingService = null, - IActivityAuditService? activityAuditService = null) + ILocalizationService localizationService, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null, + ICpuTopologyProvider? cpuTopologyProvider = null) : base(logger, enhancedLoggingService, activityAuditService) { this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); @@ -119,7 +124,8 @@ public SettingsViewModel( this.systemTrayService = systemTrayService ?? throw new ArgumentNullException(nameof(systemTrayService)); this.updateService = updateService ?? throw new ArgumentNullException(nameof(updateService)); this.versionProvider = versionProvider ?? throw new ArgumentNullException(nameof(versionProvider)); - this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + this.localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService)); + this.cpuTopologyProvider = cpuTopologyProvider; this.ApplicationVersion = this.versionProvider.DisplayVersion; @@ -127,7 +133,8 @@ public SettingsViewModel( this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone(); this.appliedThemePreference = this.settings.UseDarkTheme; - this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked"); + this.LatestUpdateVersion = this.GetLocalizedString("Settings_UpdateNotChecked", "Not checked"); + this.ReservedCpuSetsStatus = this.GetLocalizedString("SettingsView_ReservedCpuSetsUnknown", "Reserved CPU Sets: status unavailable."); this.UpdateLastCheckedText(); // Initialize commands @@ -151,11 +158,36 @@ public SettingsViewModel( this.Logger.LogInformation("Settings ViewModel initialized"); } - public override async Task InitializeAsync() - { - await this.RefreshSettingsAsync(); - await this.RefreshPowerPlansAsync(); - } + public override async Task InitializeAsync() + { + await this.RefreshSettingsAsync(); + await this.RefreshPowerPlansAsync(); + await this.RefreshReservedCpuSetsStatusAsync(); + } + + private async Task RefreshReservedCpuSetsStatusAsync() + { + if (this.cpuTopologyProvider == null) + { + return; + } + + try + { + var topology = await this.cpuTopologyProvider.GetTopologySnapshotAsync(); + this.ReservedCpuSetsStatus = topology.ReservedLogicalProcessorIndexes.Count == 0 + ? this.GetLocalizedString("SettingsView_ReservedCpuSetsNone", "Reserved CPU Sets: none detected.") + : this.GetLocalizedString( + "SettingsView_ReservedCpuSetsDetectedFormat", + "Reserved CPU Sets detected on logical processors: {0}.", + string.Join(", ", topology.ReservedLogicalProcessorIndexes)); + } + catch (Exception ex) + { + this.Logger.LogDebug(ex, "Could not read reserved CPU Set state"); + this.ReservedCpuSetsStatus = this.GetLocalizedString("SettingsView_ReservedCpuSetsUnknown", "Reserved CPU Sets: status unavailable."); + } + } private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index bff1eaa..5b479d6 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -4,6 +4,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:converters="clr-namespace:ThreadPilot.Converters" + xmlns:models="clr-namespace:ThreadPilot.Models" xmlns:Diagnostics="clr-namespace:System.Diagnostics;assembly=System.Diagnostics.Process" xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" @@ -713,6 +714,24 @@ + + + + + + + + + + + diff --git a/Views/SettingsView.xaml b/Views/SettingsView.xaml index 60cf7c1..cd89592 100644 --- a/Views/SettingsView.xaml +++ b/Views/SettingsView.xaml @@ -299,6 +299,39 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app.manifest b/app.manifest index 0bb3d59..adf9b2e 100644 --- a/app.manifest +++ b/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index cbcd75b..21b400d 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.5.3", + [string]$Version = "1.6.0", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 0c69ee5..aa50b73 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.5.3", + [string]$Version = "1.6.0", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index cd891a0..1b69016 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.5.3" + [string]$Version = "1.6.0" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index 884fad9..45336c6 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.5.3 + 1.6.0 ThreadPilot Prime Build https://github.com/PrimeBuild-pc/ThreadPilot @@ -15,7 +15,7 @@ false Advanced Windows process and power plan manager with rules automation and performance controls. ThreadPilot process and power plan manager. - https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.5.3 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.6.0 threadpilot process powerplan performance windows diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27b5725..4de73e1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to this project are documented in this file. +## v1.6.0 - CPU assignment strategies + +### Added + +- Added four explicit CPU assignment modes: ThreadPilot automatic, Affinity Mask, Ideal Processor, and CPU Sets. +- Added a persistent global default and per-rule overrides. ThreadPilot automatic is the default and preserves the established CPU Sets with safe affinity fallback behavior. +- Added topology-aware, per-thread affinity for multi-group selections and processors above CPU 63. +- Added deterministic round-robin Ideal Processor assignment with per-thread verification. +- Added read-only visibility for reserved CPU Sets and availability checks scoped to the target process. +- Added localized mode descriptions and guidance in English, Italian, French, German, Spanish, Russian, and Simplified Chinese. + +### Changed + +- Process actions, notifications, and audit entries now report the requested and effective CPU assignment strategy. +- Explicit CPU Sets and Ideal Processor modes never use a hidden affinity fallback. +- Existing rules remain in ThreadPilot automatic mode and retain their historical behavior without rewriting stored data. + +### Fixed + +- Corrected the native `GetProcessDefaultCpuSets` call contract, preventing invalid memory access while verifying process CPU Sets. +- Thread enumeration now handles threads that exit during assignment without reporting a false success. + +### Safety and compatibility + +- ThreadPilot does not write the global Windows `ReservedCpuSets` registry configuration. +- Existing restrictive affinity is not broadened automatically when selecting a soft strategy; restarting the target process may be required. +- Ideal Processor and multi-group Affinity cover newly created threads through the existing automation monitor. With monitoring disabled, only threads present at apply time are affected. + +### Validation + +- 689 automated tests passed in both Debug and Release configurations. +- All four strategies passed hardware smoke tests on a Windows 11 Hyper-V guest with four logical processors. +- Multi-group and processor indexes above 63 are covered by synthetic topology tests pending access to suitable hardware. + ## v1.5.3 - Reliability, lifecycle and threading hardening ### Fixed diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index 3c32cad..777c205 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,17 +1,26 @@ -## ThreadPilot v1.5.3 +## ThreadPilot v1.6.0 -ThreadPilot 1.5.3 hardens persistence, monitoring, notifications, autostart, tray updates, and application shutdown. +ThreadPilot 1.6.0 adds explicit CPU assignment strategies while keeping the established ThreadPilot automatic behavior as the persistent default. ### Highlights -- Saved process rules are protected from overwrite after transient read or recovery-copy failures. -- Pending Settings edits now merge safely with unrelated background updates. -- CPU monitoring preserves logical processor identity and avoids overlapping callbacks after rapid stop/start cycles. -- Notification quiet hours, retries, default hotkeys, autostart ordering, tray refreshes, and shutdown disposal are more reliable. -- Process Management now shows a clean disabled state when automation monitoring is off. +- Choose between ThreadPilot automatic, Affinity Mask, Ideal Processor, and CPU Sets. +- Save a global default or override the strategy for individual process rules. +- Apply topology-aware affinity across processor groups and processor indexes above 63. +- Distribute Ideal Processor preferences deterministically across existing process threads. +- Inspect reserved CPU Sets without changing global Windows configuration. +- Use the new controls and explanations in all seven supported languages. + +### Compatibility and safety + +- Existing settings and rules remain compatible and continue in ThreadPilot automatic mode. +- Explicit CPU Sets and Ideal Processor modes do not silently fall back to affinity. +- ThreadPilot never widens an existing hard affinity restriction; switching to a soft mode can require restarting the target process. +- With automation monitoring disabled, Ideal Processor and multi-group Affinity apply only to threads that exist at apply time. +- Reserved CPU Sets remain read-only in this release. ### Validation -- 675 automated Release tests passed before release preparation. -- CI DevSecOps and CodeQL passed on the merged reliability changes. -- The elevated Windows UI and disabled-monitoring state were manually validated. +- 689 automated tests passed in Debug and Release configurations. +- All four strategies passed hardware smoke tests on a Windows 11 Hyper-V guest with four logical processors. +- Multi-group and processor indexes above 63 passed synthetic topology tests. diff --git a/sonar-project.properties b/sonar-project.properties index 7469829..49dd148 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=threadpilot sonar.projectName=ThreadPilot -sonar.projectVersion=1.5.3 +sonar.projectVersion=1.6.0 sonar.sourceEncoding=UTF-8 sonar.sources=.