feat: add tray control, priority restoration and session logging
This commit is contained in:
1 parent
4f8f6218ad
commit
4880aa46dc
25 files changed
+3080
-684
No files matched your search
@@ -1,106 +1,111 @@
|
||||
# autoPriority
|
||||
|
||||
Отслеживает использование памяти процессами в Windows и автоматически управляет их приоритетом: ресурсоемким процессам назначает HIGH, а менее ресурсоемким снижает приоритет до NORMAL, только если он был выше. При превышении повышенного порога может включать **игровой режим**, назначая обнаруженному процессу HIGH, а всем остальным — IDLE.
|
||||
Windows-утилита для управления приоритетами процессов по расходу оперативной памяти. Работает из одного EXE, без установки и службы.
|
||||
|
||||
**Только для Windows.**
|
||||
## Запуск и выход
|
||||
|
||||
## Принцип работы
|
||||
Запустите `autoPriority.exe`. Первая проверка процессов выполняется при запуске, последующие — через заданный интервал, по умолчанию раз в минуту.
|
||||
|
||||
### Обычный режим
|
||||
Значок в трее открывает меню с пунктом **«Выход»** по левой, правой, средней или дополнительной кнопке мыши. Меню доступно и с клавиатуры. При выходе программа останавливает проверки, восстанавливает доступные для восстановления приоритеты и убирает значок. Консольная сборка также поддерживает Ctrl+C.
|
||||
|
||||
Через заданный интервал программа проверяет все запущенные процессы:
|
||||
В одном сеансе Windows работает один экземпляр. Повторный запуск завершается, не затрагивая работающий экземпляр и его журнал. Программа не настраивает автозапуск и не запрашивает повышение прав.
|
||||
|
||||
| RSS относительно `-mem` | Текущий приоритет | Действие |
|
||||
## Как назначаются приоритеты
|
||||
|
||||
Порог памяти применяется к рабочему набору процесса (RSS).
|
||||
|
||||
| Режим и условие | Приоритет |
|
||||
|---|---|
|
||||
| Обычный режим: RSS не меньше `-mem` | HIGH |
|
||||
| Обычный режим: RSS меньше `-mem`, текущий приоритет выше NORMAL | NORMAL |
|
||||
| Обычный режим: RSS меньше `-mem`, текущий приоритет NORMAL, BELOW_NORMAL или IDLE | Без изменения |
|
||||
| Игровой режим: процесс достиг `-game-mem` | HIGH |
|
||||
| Игровой режим: остальные процессы | IDLE |
|
||||
|
||||
**Игровой режим не распознаёт игры.** Его может включить любой процесс, достигший `-game-mem`, в том числе браузер. Такой процесс остаётся участником режима до завершения, даже если расход памяти уменьшился. Если участников несколько, режим заканчивается после выхода последнего.
|
||||
|
||||
После окончания игрового режима программа снимает временное понижение и применяет обычные правила. При штатном выходе самой autoPriority она восстанавливает собственные изменения, включая повышения вне игрового режима. Замеченное внешнее изменение приоритета становится новой исходной точкой.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Флаг | По умолчанию | Описание |
|
||||
|---|---|---|
|
||||
| ≥ порога | любой, кроме HIGH | → HIGH (запись `PROMOTE` в журнале) |
|
||||
| ≥ порога | уже HIGH | пропустить |
|
||||
| < порога | ABOVE_NORMAL, HIGH или REALTIME | → NORMAL (запись `DEMOTE` в журнале) |
|
||||
| < порога | NORMAL, BELOW_NORMAL или IDLE | пропустить |
|
||||
| `-mem` | `512M` | Обычный порог памяти: положительное целое число с необязательным суффиксом K/M/G |
|
||||
| `-game-mem` | `2G` | Порог игрового режима: больше `-mem`; `0` отключает режим |
|
||||
| `-interval` | `1m` | Интервал проверок; значения меньше 10 секунд ограничиваются 10 секундами |
|
||||
| `-dry-run` | `false` | Запись предполагаемых изменений в журнал без изменения приоритетов |
|
||||
|
||||
### Игровой режим (`-game-mem`)
|
||||
|
||||
Когда любой процесс достигает или превышает порог `-game-mem`:
|
||||
|
||||
1. Этому процессу назначается **HIGH** (запись `GAME` в журнале).
|
||||
2. Всем остальным процессам назначается **IDLE** с сохранением исходных приоритетов.
|
||||
3. В журнал добавляется запись `GAME MODE ON`.
|
||||
|
||||
Когда все такие процессы завершаются:
|
||||
|
||||
1. Всем процессам, приоритет которых был снижен в игровом режиме, **возвращается исходный приоритет** (запись `RESTORE` в журнале).
|
||||
2. В журнал добавляется запись `GAME MODE OFF`.
|
||||
3. Возобновляется обычный режим, и приоритеты пересчитываются по правилам `-mem`.
|
||||
|
||||
Если изменить приоритет процесса невозможно, например из-за античита или системных ограничений, процесс добавляется во внутренний список исключений и больше не изменяется (запись `BLOCK` в журнале). Его RSS продолжает измеряться, поэтому он может включить игровой режим. Процессы, память которых прочитать не удалось, пропускаются до следующей проверки.
|
||||
|
||||
При штатном завершении программа несколько раз пытается восстановить приоритеты всех процессов, измененных в игровом режиме. Если Windows отклоняет восстановление, в журнал записывается предупреждение.
|
||||
|
||||
В одном сеансе Windows может работать только один экземпляр программы. Второй экземпляр завершается, не прерывая работу первого.
|
||||
|
||||
## Сборка
|
||||
|
||||
Требуется Go 1.26 или новее.
|
||||
|
||||
```
|
||||
# Обычная сборка с окном консоли
|
||||
go build -o autopriority.exe .
|
||||
|
||||
# Фоновая сборка без консоли, минимальный размер
|
||||
go build -trimpath -buildvcs=false -gcflags="all=-l" -ldflags="-H=windowsgui -s -w -buildid=" -o autopriority.exe .
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
```
|
||||
autopriority [flags]
|
||||
```
|
||||
|
||||
| Флаг | Значение по умолчанию | Описание |
|
||||
|-------------|---------------|----------------------------------|
|
||||
| `-mem` | 512M | Порог памяти, например 512M, 1G или 2048M |
|
||||
| `-game-mem` | 2G | Порог игрового режима, например 2G или 4G. Должен быть больше `-mem`. 0 отключает режим |
|
||||
| `-interval` | 1 минута | Интервал проверки, минимум 10 секунд |
|
||||
| `-dry-run` | false | Только записывать решения в журнал, не менять приоритеты |
|
||||
|
||||
Примеры:
|
||||
|
||||
```
|
||||
# Порог 1 ГБ, проверка каждые 30 секунд
|
||||
autopriority -mem=1G -interval=30s
|
||||
|
||||
# Запуск со значениями по умолчанию: порог 512M, игровой порог 2G
|
||||
autopriority
|
||||
|
||||
# Обычный порог 512M, игровой порог 4G
|
||||
autopriority -mem=512M -game-mem=4G
|
||||
|
||||
# Пробный запуск: записывать решения, ничего не изменяя
|
||||
autopriority -dry-run
|
||||
```text
|
||||
autoPriority.exe
|
||||
autoPriority.exe -mem=1G -game-mem=4G -interval=30s
|
||||
autoPriority.exe -game-mem=0
|
||||
autoPriority.exe -dry-run
|
||||
```
|
||||
|
||||
## Журнал
|
||||
|
||||
Журнал всегда записывается в `%TEMP%\autopriority.log`. При каждом запуске создается новый файл, а предыдущий удаляется.
|
||||
Файл журнала находится во временной папке Windows: **`%TEMP%\autopriority.log`**. Путь определяется через `os.TempDir`.
|
||||
|
||||
Типы записей:
|
||||
При каждом запуске журнал очищается, архив предыдущей сессии удаляется. В текущей сессии сохраняются основной файл и один архив `autopriority.log.1`, каждый размером до 4 МиБ. Слишком длинная запись сокращается с отметкой.
|
||||
|
||||
| Префикс | Значение |
|
||||
|---|---|
|
||||
| `PROMOTE` | Приоритет повышен до HIGH в обычном режиме |
|
||||
| `DEMOTE` | Приоритет снижен до NORMAL в обычном режиме |
|
||||
| `GAME DETECT` | Впервые обнаружен процесс, превысивший порог `-game-mem` |
|
||||
| `GAME` | Процессу назначен HIGH или IDLE в игровом режиме |
|
||||
| `GAME MODE ON` | Игровой режим включен |
|
||||
| `GAME MODE OFF` | Игровой режим выключен, приоритеты восстановлены |
|
||||
| `BLOCK` | Ошибка OpenProcess или SetPriorityClass, процесс добавлен в список исключений |
|
||||
| `SKIP` | Не удалось прочитать сведения о процессе, попытка повторится при следующей проверке |
|
||||
| `RESTORE` | Восстановлен исходный приоритет при выходе из игрового режима или завершении программы |
|
||||
| `[DRY-RUN]` | Приоритет был бы изменен в пробном режиме |
|
||||
Изменения выводятся ASCII-таблицей:
|
||||
|
||||
## Автозапуск
|
||||
```text
|
||||
Процесс | PID | Память | Повышено | Понижено
|
||||
```
|
||||
|
||||
Нажмите **Win+R**, введите `shell:startup` и нажмите Enter. Поместите ярлык `autopriority.exe` в открывшуюся папку.
|
||||
При включении игрового режима его участники идут первыми, фоновые процессы — ниже. Уже установленный HIGH отмечается как «уже HIGH». Если изменить приоритет участника не удалось, его строка содержит отметку «в блоклисте».
|
||||
|
||||
## Зависимости
|
||||
Ошибки выводятся отдельным блоком «ОШИБКИ»:
|
||||
|
||||
Отсутствуют. Программа напрямую вызывает Windows API (`kernel32`, `psapi`) через syscall.
|
||||
```text
|
||||
Процесс | PID | Описание ошибки
|
||||
```
|
||||
|
||||
Под таблицей нет дублирующих сообщений. Режим `-dry-run` явно помечает записи как план. При ошибке записи журнала программа завершает работу с попыткой восстановления приоритетов.
|
||||
|
||||
## Блоклист
|
||||
|
||||
После ошибки чтения или изменения приоритета процесс попадает в блоклист. Повторных попыток для него нет, в том числе при выходе; остальные процессы обрабатываются независимо. Блоклист хранится в памяти и очищается при перезапуске autoPriority.
|
||||
|
||||
Если время создания доступно, блокировка привязана к PID и времени создания. Другой экземпляр с тем же PID обрабатывается отдельно. Если Windows запрещает даже чтение процесса, запись снимается при исчезновении PID из снимка либо смене имени или родителя. Замену недоступного процесса другим с тем же PID, именем и родителем между двумя снимками надёжно различить нельзя.
|
||||
|
||||
Процесс, доступный для чтения, но недоступный для изменения, может включать игровой режим по расходу памяти.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Восстановление незаблокированного процесса выполняется один раз. Если Windows отказывает в доступе, программа сообщает о невосстановленных приоритетах.
|
||||
- Принудительное завершение, сбой ОС или отключение питания не позволяют гарантировать восстановление.
|
||||
- Между чтением и записью приоритета другая программа может изменить его значение.
|
||||
- Наследованный дочерними процессами IDLE определяется по родству и времени создания. Это эвристика: она может затронуть явно заданный ребёнку IDLE и не обнаружить цепочку, если родитель завершился между проверками.
|
||||
- Программа не обходит защиту процессов, не включает SeDebugPrivilege и не гарантирует совместимость с античитом или прирост FPS.
|
||||
|
||||
## Сборка и проверки
|
||||
|
||||
Для сборки нужны Windows и Go 1.26 или новее. Сторонние Go-модули и CGO не требуются.
|
||||
|
||||
```text
|
||||
go build -trimpath -buildvcs=false -ldflags="-H=windowsgui -s -w -buildid=" -o autoPriority.exe .
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Для консольной сборки уберите `-H=windowsgui`.
|
||||
|
||||
Обычные тесты используют синтетические процессы. Нативные проверки включаются переменными окружения:
|
||||
|
||||
- `AP_NATIVE_PROCESSES=1`: проверка Windows API только на специально созданных дочерних процессах.
|
||||
- `AP_NATIVE_GUI=1`: проверка собственного значка и меню трея; может кратко переключить фокус.
|
||||
|
||||
Нативные тесты не запускают системный сканер программы. Запускайте их вне защищённой игры.
|
||||
|
||||
## Иконки
|
||||
|
||||
Программа использует `chart_bar_edit` для трея и `cross` для пункта «Выход». PNG встроены в EXE.
|
||||
|
||||
Silk 1.3, **Mark James**, Creative Commons Attribution 2.5.
|
||||
|
||||
- Автор: http://www.famfamfam.com/lab/icons/silk/
|
||||
- Источник: https://github.com/legacy-icons/famfamfam-silk
|
||||
- Ревизия: `d6056f5c28e46c28403888ab7c013aced3928ec8`.
|
||||
- Лицензия и контрольные суммы: `assets/SILK-LICENSE.md`, `assets/SILK-SOURCE.json`.
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOptionsValidation(t *testing.T) {
|
||||
for _, args := range [][]string{{"-mem=0"}, {"-mem=1.5G"}, {"-game-mem=128M"}, {"-interval=bad"}, {"extra"}, {"-unknown"}} {
|
||||
if _, err := readOptions(args, io.Discard); err == nil {
|
||||
t.Fatalf("accepted %v", args)
|
||||
}
|
||||
}
|
||||
o, err := readOptions([]string{"-interval=1ms", "-game-mem=0"}, io.Discard)
|
||||
if err != nil || o.mem != 512<<20 || o.gameMem != 0 || o.interval != 10*time.Second {
|
||||
t.Fatal(o, err)
|
||||
}
|
||||
if _, err = readOptions([]string{"-h"}, io.Discard); !errors.Is(err, flag.ErrHelp) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestRunControllerCancellationRestores(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassBelowNormal)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
err := runController(ctx, options{mem: 100, gameMem: 200, interval: time.Minute}, f, func(scanReport) error {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
assertPriority(t, p, PriorityClassHigh)
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
if calls != 2 {
|
||||
t.Fatal("shutdown report missing")
|
||||
}
|
||||
}
|
||||
func TestRunControllerLogFailureStillRestores(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
sentinel := errors.New("disk failure")
|
||||
err := runController(context.Background(), options{mem: 100, interval: time.Minute}, f, func(scanReport) error { return sentinel })
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestCancelledBeforeStartupDoesNotScan(t *testing.T) {
|
||||
f := newFake()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := runController(ctx, options{mem: 100, interval: time.Minute}, f, func(scanReport) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.snapshots != 0 {
|
||||
t.Fatal("cancelled startup still scanned processes")
|
||||
}
|
||||
}
|
||||
func TestWaitResultContract(t *testing.T) {
|
||||
for _, item := range []struct {
|
||||
code uintptr
|
||||
alive, bad bool
|
||||
}{{0, false, false}, {0x102, true, false}, {0xffffffff, false, true}, {0x80, false, true}} {
|
||||
alive, err := decodeWaitResult(item.code, syscall.Errno(6))
|
||||
if alive != item.alive || (err != nil) != item.bad {
|
||||
t.Fatalf("bad wait decoding for %#x", item.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
Original readme (from author of the icons)
|
||||
----------
|
||||
|
||||
Silk icon set 1.3
|
||||
|
||||
_________________________________________
|
||||
|
||||
Mark James
|
||||
http://www.famfamfam.com/lab/icons/silk/
|
||||
|
||||
_________________________________________
|
||||
|
||||
This work is licensed under a
|
||||
Creative Commons Attribution 2.5 License.
|
||||
[ http://creativecommons.org/licenses/by/2.5/ ]
|
||||
|
||||
This means you may use it for any purpose,
|
||||
and make any changes you like.
|
||||
All I ask is that you include a link back
|
||||
to this page in your credits.
|
||||
|
||||
Are you using this icon set? Send me an email
|
||||
(including a link or picture if available) to
|
||||
mjames@gmail.com
|
||||
|
||||
Any other questions about this icon set please
|
||||
contact mjames@gmail.com
|
||||
|
||||
|
||||
|
||||
About the rest (all this repository but the icons)
|
||||
----------
|
||||
|
||||
All the content of this repository (excepted the icon pack)
|
||||
is licensed under the [MIT license](http://opensource.org/licenses/MIT).
|
||||
|
||||
Though, it is just composed a few trivial json files and a Readme.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"repository": "legacy-icons/famfamfam-silk",
|
||||
"commit": "d6056f5c28e46c28403888ab7c013aced3928ec8",
|
||||
"files": [
|
||||
{
|
||||
"name": "chart_bar_edit.png",
|
||||
"path": "src/chart_bar_edit.png",
|
||||
"bytes": 754,
|
||||
"sha256": "f52dca6b882152f3a4f2459ea8a02a75a6d925bf18082cae31bb0ecc254fb9eb",
|
||||
"identical_to_previous_asset": true
|
||||
},
|
||||
{
|
||||
"name": "cross.png",
|
||||
"path": "src/cross.png",
|
||||
"bytes": 655,
|
||||
"sha256": "d04ecfc93ff86c44f6fc39e35945e3d8a7648ba8fcd97a2635920df2e88893b3",
|
||||
"identical_to_previous_asset": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 754 B |
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
+420
@@ -0,0 +1,420 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type processError struct {
|
||||
Name string
|
||||
PID uint32
|
||||
Description string
|
||||
}
|
||||
type processSnapshot struct {
|
||||
Processes []procInfo
|
||||
// Complete PID membership, including processes whose metadata was inaccessible.
|
||||
PIDs map[uint32]bool
|
||||
Errors []processError
|
||||
}
|
||||
type processBackend interface {
|
||||
Snapshot() (processSnapshot, error)
|
||||
Alive(*procInfo) (bool, error)
|
||||
Priority(*procInfo) (uint32, error)
|
||||
SetPriority(*procInfo, uint32) error
|
||||
Close(*procInfo)
|
||||
}
|
||||
type priorityChange struct {
|
||||
Name string
|
||||
PID uint32
|
||||
RSS uint64
|
||||
Before, After uint32
|
||||
Heavy, Unchanged, Blocked bool
|
||||
}
|
||||
type scanReport struct {
|
||||
Title string
|
||||
Rows []priorityChange
|
||||
Errors []processError
|
||||
DryRun bool
|
||||
Unrestored int
|
||||
}
|
||||
type processState struct {
|
||||
info procInfo
|
||||
original, last uint32
|
||||
idleSince uint64
|
||||
changed, game, overlay bool
|
||||
gameBase uint32
|
||||
}
|
||||
type changeKind uint8
|
||||
|
||||
const (
|
||||
normalChange changeKind = iota
|
||||
gameChange
|
||||
restoringChange
|
||||
)
|
||||
|
||||
type controller struct {
|
||||
api processBackend
|
||||
mem, gameMem uint64
|
||||
dryRun, gameMode bool
|
||||
states map[processKey]*processState
|
||||
now func() time.Time
|
||||
blocked map[processKey]bool
|
||||
out scanReport
|
||||
}
|
||||
|
||||
func newController(api processBackend, mem, gameMem uint64, dryRun bool, _ time.Duration) *controller {
|
||||
return &controller{api: api, mem: mem, gameMem: gameMem, dryRun: dryRun, states: make(map[processKey]*processState), blocked: make(map[processKey]bool), now: time.Now}
|
||||
}
|
||||
func (c *controller) ordered() []*processState {
|
||||
list := make([]*processState, 0, len(c.states))
|
||||
for _, s := range c.states {
|
||||
list = append(list, s)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
a, b := list[i], list[j]
|
||||
if a.game != b.game {
|
||||
return a.game
|
||||
}
|
||||
if a.info.PID != b.info.PID {
|
||||
return a.info.PID < b.info.PID
|
||||
}
|
||||
return a.info.Key.Created < b.info.Key.Created
|
||||
})
|
||||
return list
|
||||
}
|
||||
func (c *controller) alive(s *processState, snapshot *processSnapshot) (bool, error) {
|
||||
if snapshot != nil {
|
||||
if !snapshot.PIDs[s.info.PID] {
|
||||
return false, nil
|
||||
}
|
||||
for _, p := range snapshot.Processes {
|
||||
if p.PID == s.info.PID && p.Key != s.info.Key {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.info.Waitable {
|
||||
return c.api.Alive(&s.info)
|
||||
}
|
||||
// Without SYNCHRONIZE, only absence from a complete snapshot proves exit.
|
||||
// Retained handles still bind all restoration writes to the original instance.
|
||||
return true, nil
|
||||
}
|
||||
func (s *processState) observe(current uint32) {
|
||||
if (s.changed || s.overlay) && current != s.last {
|
||||
// Do not undo another actor's change. The next own write starts a new baseline.
|
||||
s.changed = false
|
||||
s.overlay = false
|
||||
s.idleSince = 0
|
||||
}
|
||||
s.info.Prio = current
|
||||
if !s.changed && !s.overlay {
|
||||
s.original = current
|
||||
}
|
||||
}
|
||||
func (c *controller) reconcile(snapshot processSnapshot) map[processKey]bool {
|
||||
fresh := make(map[processKey]bool, len(snapshot.Processes))
|
||||
instances := make(map[uint32]processKey, len(snapshot.Processes))
|
||||
for _, p := range snapshot.Processes {
|
||||
instances[p.PID] = p.Key
|
||||
}
|
||||
for key := range c.blocked {
|
||||
current, known := instances[key.PID]
|
||||
if !snapshot.PIDs[key.PID] || (known && current != key) {
|
||||
delete(c.blocked, key)
|
||||
}
|
||||
}
|
||||
for key, s := range c.states {
|
||||
alive, err := c.alive(s, &snapshot)
|
||||
if err != nil {
|
||||
c.failed(s, s.info.Prio, fmt.Errorf("статус процесса: %w", err))
|
||||
}
|
||||
if err == nil && !alive {
|
||||
c.api.Close(&s.info)
|
||||
delete(c.states, key)
|
||||
}
|
||||
}
|
||||
for i := range snapshot.Processes {
|
||||
p := &snapshot.Processes[i]
|
||||
fresh[p.Key] = true
|
||||
if s := c.states[p.Key]; s != nil {
|
||||
s.info.Name = p.Name
|
||||
s.info.RSS = p.RSS
|
||||
s.info.ParentPID = p.ParentPID
|
||||
s.observe(p.Prio)
|
||||
c.api.Close(p)
|
||||
} else {
|
||||
c.states[p.Key] = &processState{info: *p, original: p.Prio}
|
||||
}
|
||||
}
|
||||
c.out.Errors = append(c.out.Errors, snapshot.Errors...)
|
||||
return fresh
|
||||
}
|
||||
func (c *controller) failed(s *processState, target uint32, err error) {
|
||||
if c.blocked[s.info.Key] {
|
||||
return
|
||||
}
|
||||
c.blocked[s.info.Key] = true
|
||||
c.out.Errors = append(c.out.Errors, processError{Name: s.info.Name, PID: s.info.PID, Description: fmt.Sprintf("%s: %v; внесён в блоклист", prioName(target), err)})
|
||||
}
|
||||
func (c *controller) change(s *processState, target uint32, kind changeKind) bool {
|
||||
if c.blocked[s.info.Key] {
|
||||
return false
|
||||
}
|
||||
current, err := c.api.Priority(&s.info)
|
||||
if err != nil {
|
||||
c.failed(s, target, err)
|
||||
return false
|
||||
}
|
||||
s.observe(current)
|
||||
if kind == normalChange && desiredPriority(s.info.RSS, c.mem, current, false, false) == 0 {
|
||||
return true
|
||||
}
|
||||
if kind == restoringChange && !s.changed && !s.overlay {
|
||||
return true
|
||||
}
|
||||
if current == target {
|
||||
return true
|
||||
}
|
||||
row := priorityChange{Name: s.info.Name, PID: s.info.PID, RSS: s.info.RSS, Before: current, After: target, Heavy: s.game}
|
||||
if c.dryRun {
|
||||
c.out.Rows = append(c.out.Rows, row)
|
||||
return true
|
||||
}
|
||||
stamp := filetimeValue(c.now())
|
||||
if err = c.api.SetPriority(&s.info, target); err != nil {
|
||||
c.failed(s, target, err)
|
||||
return false
|
||||
}
|
||||
// Save ownership immediately after a successful write, even if verification fails.
|
||||
if !s.changed {
|
||||
s.original = current
|
||||
}
|
||||
if kind == gameChange && !s.overlay {
|
||||
s.overlay = true
|
||||
s.gameBase = current
|
||||
}
|
||||
s.changed = true
|
||||
s.last = target
|
||||
s.info.Prio = target
|
||||
if target == PriorityClassIdle {
|
||||
s.idleSince = stamp
|
||||
} else {
|
||||
s.idleSince = 0
|
||||
}
|
||||
actual, err := c.api.Priority(&s.info)
|
||||
if err != nil {
|
||||
c.failed(s, target, fmt.Errorf("изменение выполнено, проверка не удалась: %w", err))
|
||||
return false
|
||||
}
|
||||
s.last = actual
|
||||
s.info.Prio = actual
|
||||
s.changed = actual != s.original
|
||||
if actual != PriorityClassIdle {
|
||||
s.idleSince = 0
|
||||
}
|
||||
if actual != current {
|
||||
row.After = actual
|
||||
c.out.Rows = append(c.out.Rows, row)
|
||||
}
|
||||
if actual != target {
|
||||
c.failed(s, target, fmt.Errorf("Windows установила %s вместо %s", prioName(actual), prioName(target)))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (c *controller) restoreOverlay(s *processState) bool {
|
||||
if c.blocked[s.info.Key] {
|
||||
return false
|
||||
}
|
||||
current, err := c.api.Priority(&s.info)
|
||||
if err != nil {
|
||||
c.failed(s, s.gameBase, err)
|
||||
return false
|
||||
}
|
||||
s.observe(current)
|
||||
if !s.overlay {
|
||||
return true
|
||||
}
|
||||
if !c.change(s, s.gameBase, restoringChange) {
|
||||
return false
|
||||
}
|
||||
s.overlay = false
|
||||
return true
|
||||
}
|
||||
func filetimeValue(t time.Time) uint64 {
|
||||
return uint64(t.Unix())*10000000 + uint64(t.Nanosecond()/100) + 116444736000000000
|
||||
}
|
||||
func inheritedClass(parent uint32) uint32 {
|
||||
if parent == PriorityClassIdle || parent == PriorityClassBelowNormal {
|
||||
return parent
|
||||
}
|
||||
return PriorityClassNormal
|
||||
}
|
||||
func (c *controller) adoptInherited(fresh map[processKey]bool) {
|
||||
if c.dryRun {
|
||||
return
|
||||
}
|
||||
byPID := make(map[uint32]*processState, len(c.states))
|
||||
for _, s := range c.states {
|
||||
byPID[s.info.PID] = s
|
||||
}
|
||||
// Each successful pass adopts at least one previously unowned child. Cycles,
|
||||
// reused parent PIDs and pre-existing IDLE processes cannot establish ownership.
|
||||
for pass := 0; pass < len(c.states); pass++ {
|
||||
adopted := false
|
||||
for _, s := range c.states {
|
||||
if !fresh[s.info.Key] || s.changed || s.overlay || s.game || s.info.Prio != PriorityClassIdle {
|
||||
continue
|
||||
}
|
||||
parent := byPID[s.info.ParentPID]
|
||||
if parent == nil || parent == s || !fresh[parent.info.Key] || (!parent.changed && !parent.overlay) || parent.last != PriorityClassIdle || parent.info.Prio != PriorityClassIdle || parent.idleSince == 0 {
|
||||
continue
|
||||
}
|
||||
if s.info.Key.Created < parent.idleSince || s.info.Key.Created <= parent.info.Key.Created {
|
||||
continue
|
||||
}
|
||||
s.original = inheritedClass(parent.original)
|
||||
s.last = PriorityClassIdle
|
||||
s.changed = s.original != s.last
|
||||
s.overlay = parent.overlay
|
||||
s.gameBase = inheritedClass(parent.gameBase)
|
||||
s.idleSince = s.info.Key.Created
|
||||
// Explicit child IDLE cannot be distinguished from inherited IDLE by polling.
|
||||
adopted = adopted || s.changed || s.overlay
|
||||
}
|
||||
if !adopted {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *controller) releaseUnused() {
|
||||
for key, s := range c.states {
|
||||
if !s.game && !s.changed && !s.overlay {
|
||||
c.api.Close(&s.info)
|
||||
delete(c.states, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *controller) Step() scanReport {
|
||||
c.out = scanReport{DryRun: c.dryRun}
|
||||
snapshot, err := c.api.Snapshot()
|
||||
if err != nil {
|
||||
c.out.Errors = append(c.out.Errors, processError{Description: fmt.Sprintf("Снимок процессов: %v", err)})
|
||||
return c.out
|
||||
}
|
||||
fresh := c.reconcile(snapshot)
|
||||
c.adoptInherited(fresh)
|
||||
newGames := make(map[processKey]bool)
|
||||
for key, s := range c.states {
|
||||
if fresh[key] && c.gameMem > 0 && s.info.RSS >= c.gameMem && !s.game {
|
||||
s.game = true
|
||||
newGames[key] = true
|
||||
}
|
||||
}
|
||||
hasGame := false
|
||||
for _, s := range c.states {
|
||||
hasGame = hasGame || s.game
|
||||
}
|
||||
if hasGame && !c.gameMode {
|
||||
c.out.Title = "ИГРОВОЙ РЕЖИМ ВКЛЮЧЁН"
|
||||
}
|
||||
if !hasGame && c.gameMode {
|
||||
c.out.Title = "ВОССТАНОВЛЕНИЕ ПРИОРИТЕТОВ"
|
||||
}
|
||||
c.gameMode = hasGame
|
||||
for _, s := range c.ordered() {
|
||||
if !hasGame && s.overlay {
|
||||
if !c.restoreOverlay(s) {
|
||||
continue
|
||||
}
|
||||
if c.out.Title == "" {
|
||||
c.out.Title = "ВОССТАНОВЛЕНИЕ ПРИОРИТЕТОВ"
|
||||
}
|
||||
}
|
||||
if !fresh[s.info.Key] {
|
||||
continue
|
||||
}
|
||||
target := desiredPriority(s.info.RSS, c.mem, s.info.Prio, hasGame, s.game)
|
||||
if target == 0 {
|
||||
continue
|
||||
}
|
||||
if target == s.info.Prio {
|
||||
if newGames[s.info.Key] {
|
||||
c.out.Rows = append(c.out.Rows, priorityChange{Name: s.info.Name, PID: s.info.PID, RSS: s.info.RSS, Before: target, After: target, Heavy: true, Unchanged: true})
|
||||
}
|
||||
continue
|
||||
}
|
||||
kind := normalChange
|
||||
if hasGame {
|
||||
kind = gameChange
|
||||
}
|
||||
if !c.change(s, target, kind) && newGames[s.info.Key] {
|
||||
c.out.Rows = append(c.out.Rows, priorityChange{Name: s.info.Name, PID: s.info.PID, RSS: s.info.RSS, Before: s.info.Prio, After: s.info.Prio, Heavy: true, Blocked: true})
|
||||
}
|
||||
}
|
||||
c.releaseUnused()
|
||||
if c.out.Title == "" && len(c.out.Rows) > 0 {
|
||||
if hasGame {
|
||||
c.out.Title = "ИГРОВОЙ РЕЖИМ"
|
||||
} else {
|
||||
c.out.Title = "ИЗМЕНЕНИЕ ПРИОРИТЕТОВ"
|
||||
}
|
||||
}
|
||||
return c.out
|
||||
}
|
||||
func (c *controller) Shutdown() scanReport {
|
||||
c.out = scanReport{Title: "ЗАВЕРШЕНИЕ: ВОССТАНОВЛЕНИЕ ПРИОРИТЕТОВ", DryRun: c.dryRun}
|
||||
if !c.dryRun && len(c.states) > 0 {
|
||||
snapshot, err := c.api.Snapshot()
|
||||
if err == nil {
|
||||
fresh := c.reconcile(snapshot)
|
||||
c.adoptInherited(fresh)
|
||||
} else {
|
||||
c.out.Errors = append(c.out.Errors, processError{Description: fmt.Sprintf("Последний снимок процессов: %v", err)})
|
||||
}
|
||||
for _, s := range c.ordered() {
|
||||
if !s.changed || c.blocked[s.info.Key] {
|
||||
continue
|
||||
}
|
||||
alive, err := c.alive(s, nil)
|
||||
if err == nil && !alive {
|
||||
s.changed = false
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
c.failed(s, s.original, err)
|
||||
continue
|
||||
}
|
||||
current, err := c.api.Priority(&s.info)
|
||||
if err != nil {
|
||||
c.failed(s, s.original, err)
|
||||
continue
|
||||
}
|
||||
s.observe(current)
|
||||
if !s.changed {
|
||||
continue
|
||||
}
|
||||
if c.change(s, s.original, restoringChange) {
|
||||
s.changed = false
|
||||
s.overlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, s := range c.states {
|
||||
if s.changed {
|
||||
c.out.Unrestored++
|
||||
}
|
||||
c.api.Close(&s.info)
|
||||
delete(c.states, key)
|
||||
}
|
||||
if c.out.Unrestored > 0 {
|
||||
c.out.Errors = append(c.out.Errors, processError{Description: fmt.Sprintf("Не восстановлены приоритеты %d процессов; подробности в журнале", c.out.Unrestored)})
|
||||
}
|
||||
c.gameMode = false
|
||||
clear(c.blocked)
|
||||
return c.out
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestControllerNormalBoostRestoredAtExit(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassHigh)
|
||||
c.Shutdown()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerHeavyRemainsTrackedBelowThresholdAndDuringReadFailure(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
g.info.RSS = 1
|
||||
c.Step()
|
||||
assertPriority(t, g, PriorityClassHigh)
|
||||
g.readable = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassIdle)
|
||||
g.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerProtectedHeavyStillEnablesMode(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
g.writable = false
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
r := c.Step()
|
||||
assertPriority(t, g, PriorityClassNormal)
|
||||
assertPriority(t, p, PriorityClassIdle)
|
||||
if len(r.Errors) == 0 {
|
||||
t.Fatal("missing denied-write diagnostic")
|
||||
}
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerDenialPersistsForCurrentInstance(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
p.writable = false
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
p.writable = true
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
c.now = func() time.Time { return time.Unix(1061, 0) }
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
c.Shutdown()
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerPIDReuseNeverRestoresReplacement(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
old := f.add(20, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
old.alive = false
|
||||
replacement := f.child(20, 0, 116444736000000000+1001*10000000)
|
||||
replacement.info.Prio = PriorityClassBelowNormal
|
||||
g.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, replacement, PriorityClassBelowNormal)
|
||||
if replacement.writes != 0 {
|
||||
t.Fatal("wrote to a replacement process")
|
||||
}
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerExternalPriorityBecomesNewBaseline(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
p.info.Prio = PriorityClassAboveNormal
|
||||
c.Shutdown()
|
||||
assertPriority(t, p, PriorityClassAboveNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerExternalChangeBeforeNextPolicyWriteIsPreservedOnExit(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
p.info.Prio = PriorityClassBelowNormal
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassHigh)
|
||||
c.Shutdown()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerGameOverlayKeepsOriginalExitBaseline(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(20, 150, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassIdle)
|
||||
g.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassHigh)
|
||||
c.Shutdown()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerFinalSnapshotCatchesNewChild(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
parent := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
child := f.child(30, 20, 116444736000000000+1001*10000000)
|
||||
c.Shutdown()
|
||||
assertPriority(t, g, PriorityClassNormal)
|
||||
assertPriority(t, parent, PriorityClassNormal)
|
||||
assertPriority(t, child, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerDryRunHasNoWritesOrRestores(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p := f.add(20, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.dryRun = true
|
||||
r := c.Step()
|
||||
if !r.DryRun || len(r.Rows) != 2 {
|
||||
t.Fatalf("bad dry-run report: %+v", r)
|
||||
}
|
||||
c.Shutdown()
|
||||
assertPriority(t, g, PriorityClassNormal)
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
if g.writes+p.writes != 0 {
|
||||
t.Fatal("dry-run wrote priorities")
|
||||
}
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerShutdownWorksWithoutFinalSnapshot(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
f.snapshotErr = true
|
||||
r := c.Shutdown()
|
||||
if r.Unrestored != 0 {
|
||||
t.Fatal(r)
|
||||
}
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerUnrestoredFailuresReportedAndHandlesClosed(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
p.writable = false
|
||||
r := c.Shutdown()
|
||||
if r.Unrestored != 1 {
|
||||
t.Fatalf("expected one unresolved restoration, got %+v", r)
|
||||
}
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerWindowsCoercionIsNotSuccessfulRestoration(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassRealtime)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
p.coerced = PriorityClassHigh
|
||||
r := c.Shutdown()
|
||||
if r.Unrestored != 1 {
|
||||
t.Fatal("coerced REALTIME restoration was falsely reported as successful")
|
||||
}
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerNoSynchronizeUsesCompletePIDMembership(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
c.states[g.info.Key].info.Waitable = false
|
||||
g.readable = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassIdle)
|
||||
g.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerSteadyStateDoesNotRepeatChanges(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassHigh)
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
r := c.Step()
|
||||
if len(r.Rows) != 2 || !r.Rows[0].Unchanged {
|
||||
t.Fatal("heavy trigger must be first, without a fictional raise")
|
||||
}
|
||||
writes := g.writes + p.writes
|
||||
r = c.Step()
|
||||
if len(r.Rows) != 0 || g.writes+p.writes != writes {
|
||||
t.Fatal("repeated unchanged work")
|
||||
}
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerExternalChangeToNetUnchangedOverlayIsRespected(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(20, 150, PriorityClassIdle)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p.info.RSS = 50
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassIdle)
|
||||
p.info.Prio = PriorityClassBelowNormal
|
||||
g.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerRestorationRechecksOwnershipImmediatelyBeforeWrite(t *testing.T) {
|
||||
for _, shutdown := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "game exit", true: "shutdown"}[shutdown], func(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
g.alive = false
|
||||
reads := 0
|
||||
f.onRead = func(v *fakeProcess) {
|
||||
if v == p {
|
||||
reads++
|
||||
if reads == 2 {
|
||||
v.info.Prio = PriorityClassBelowNormal
|
||||
}
|
||||
}
|
||||
}
|
||||
if shutdown {
|
||||
c.Shutdown()
|
||||
} else {
|
||||
c.Step()
|
||||
f.onRead = nil
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
c.Shutdown()
|
||||
}
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestControllerReadFailureIsBlocked(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
p := f.add(20, 50, PriorityClassNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
g.alive = false
|
||||
p.failRead = true
|
||||
c.Step()
|
||||
r := c.Step()
|
||||
if len(r.Errors) != 0 {
|
||||
t.Fatal("blocked restoration repeated its error", r.Errors)
|
||||
}
|
||||
p.failRead = false
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Only OS boundaries are simulated. The real production controller is exercised.
|
||||
type fakeProcess struct {
|
||||
info procInfo
|
||||
alive, readable, writable bool
|
||||
failSet, failRead bool
|
||||
coerced uint32
|
||||
writes int
|
||||
}
|
||||
type fakeBackend struct {
|
||||
procs map[processKey]*fakeProcess
|
||||
handles map[syscall.Handle]*fakeProcess
|
||||
next syscall.Handle
|
||||
snapshotErr bool
|
||||
onRead func(*fakeProcess)
|
||||
snapshots int
|
||||
}
|
||||
|
||||
func newFake() *fakeBackend {
|
||||
return &fakeBackend{procs: make(map[processKey]*fakeProcess), handles: make(map[syscall.Handle]*fakeProcess)}
|
||||
}
|
||||
func (f *fakeBackend) add(pid uint32, rss uint64, prio uint32) *fakeProcess {
|
||||
p := &fakeProcess{info: procInfo{PID: pid, Key: processKey{pid, uint64(pid)}, Name: fmt.Sprintf("Process%d.exe", pid), RSS: rss, Prio: prio}, alive: true, readable: true, writable: true}
|
||||
f.procs[p.info.Key] = p
|
||||
return p
|
||||
}
|
||||
func (f *fakeBackend) Snapshot() (processSnapshot, error) {
|
||||
f.snapshots++
|
||||
if f.snapshotErr {
|
||||
return processSnapshot{}, errors.New("snapshot unavailable")
|
||||
}
|
||||
out := processSnapshot{PIDs: make(map[uint32]bool)}
|
||||
keys := make([]processKey, 0, len(f.procs))
|
||||
for key := range f.procs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i].PID < keys[j].PID })
|
||||
for _, key := range keys {
|
||||
p := f.procs[key]
|
||||
if !p.alive {
|
||||
continue
|
||||
}
|
||||
out.PIDs[key.PID] = true
|
||||
if !p.readable {
|
||||
continue
|
||||
}
|
||||
info := p.info
|
||||
f.next++
|
||||
info.Handle = f.next
|
||||
info.Waitable = true
|
||||
f.handles[info.Handle] = p
|
||||
out.Processes = append(out.Processes, info)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeBackend) Alive(p *procInfo) (bool, error) {
|
||||
v, ok := f.handles[p.Handle]
|
||||
if !ok {
|
||||
return false, errors.New("invalid handle")
|
||||
}
|
||||
return v.alive, nil
|
||||
}
|
||||
func (f *fakeBackend) Priority(p *procInfo) (uint32, error) {
|
||||
v, ok := f.handles[p.Handle]
|
||||
if !ok || !v.alive || v.failRead {
|
||||
return 0, errors.New("priority unavailable")
|
||||
}
|
||||
if f.onRead != nil {
|
||||
f.onRead(v)
|
||||
}
|
||||
return v.info.Prio, nil
|
||||
}
|
||||
func (f *fakeBackend) SetPriority(p *procInfo, target uint32) error {
|
||||
v, ok := f.handles[p.Handle]
|
||||
if !ok || !v.alive || !v.writable || v.failSet {
|
||||
return errors.New("access denied")
|
||||
}
|
||||
v.writes++
|
||||
v.info.Prio = target
|
||||
if v.coerced != 0 {
|
||||
v.info.Prio = v.coerced
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeBackend) Close(p *procInfo) {
|
||||
if p.Handle == 0 {
|
||||
panic("double close")
|
||||
}
|
||||
if _, ok := f.handles[p.Handle]; !ok {
|
||||
panic("invalid close")
|
||||
}
|
||||
delete(f.handles, p.Handle)
|
||||
p.Handle = 0
|
||||
}
|
||||
func testController(f *fakeBackend) *controller {
|
||||
c := newController(f, 100, 200, false, time.Minute)
|
||||
c.now = func() time.Time { return time.Unix(1000, 0) }
|
||||
return c
|
||||
}
|
||||
func assertPriority(t *testing.T, p *fakeProcess, want uint32) {
|
||||
t.Helper()
|
||||
if p.info.Prio != want {
|
||||
t.Fatalf("%s: got %s; want %s", p.info.Name, prioName(p.info.Prio), prioName(want))
|
||||
}
|
||||
}
|
||||
func (f *fakeBackend) child(pid, parent uint32, created uint64) *fakeProcess {
|
||||
p := f.add(pid, 50, PriorityClassIdle)
|
||||
delete(f.procs, p.info.Key)
|
||||
p.info.Key.Created = created
|
||||
p.info.ParentPID = parent
|
||||
f.procs[p.info.Key] = p
|
||||
return p
|
||||
}
|
||||
func TestControllerInheritedIdleDescendantsRecover(t *testing.T) {
|
||||
f := newFake()
|
||||
game := f.add(10, 250, PriorityClassNormal)
|
||||
parent := f.add(20, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
child := f.child(30, 20, 116444736000000000+1001*10000000)
|
||||
grandchild := f.child(40, 30, 116444736000000000+1002*10000000)
|
||||
oldIdle := f.child(50, 20, 116444736000000000+999*10000000)
|
||||
unrelated := f.child(60, 0, 116444736000000000+1001*10000000)
|
||||
c.now = func() time.Time { return time.Unix(1003, 0) }
|
||||
c.Step()
|
||||
game.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, parent, PriorityClassBelowNormal)
|
||||
assertPriority(t, child, PriorityClassBelowNormal)
|
||||
assertPriority(t, grandchild, PriorityClassBelowNormal)
|
||||
assertPriority(t, oldIdle, PriorityClassIdle)
|
||||
assertPriority(t, unrelated, PriorityClassIdle)
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func assertClosed(t *testing.T, f *fakeBackend) {
|
||||
t.Helper()
|
||||
if len(f.handles) != 0 {
|
||||
t.Fatalf("leaked %d owned handles", len(f.handles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerShutdownRestoresHeavyAndBackground(t *testing.T) {
|
||||
f := newFake()
|
||||
game := f.add(10, 250, PriorityClassNormal)
|
||||
background := f.add(20, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
assertPriority(t, game, PriorityClassHigh)
|
||||
assertPriority(t, background, PriorityClassIdle)
|
||||
report := c.Shutdown()
|
||||
if len(report.Errors) != 0 {
|
||||
t.Fatal(report.Errors)
|
||||
}
|
||||
assertPriority(t, game, PriorityClassNormal)
|
||||
assertPriority(t, background, PriorityClassBelowNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerGameExitRestoresBackground(t *testing.T) {
|
||||
f := newFake()
|
||||
game := f.add(10, 250, PriorityClassNormal)
|
||||
background := f.add(20, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
game.alive = false
|
||||
c.Step()
|
||||
assertPriority(t, background, PriorityClassBelowNormal)
|
||||
c.Shutdown()
|
||||
assertClosed(t, f)
|
||||
}
|
||||
func TestControllerRestoreFailureDoesNotBlockOtherProcesses(t *testing.T) {
|
||||
f := newFake()
|
||||
game := f.add(10, 250, PriorityClassNormal)
|
||||
blocked := f.add(20, 50, PriorityClassNormal)
|
||||
healthy := f.add(30, 50, PriorityClassBelowNormal)
|
||||
c := testController(f)
|
||||
c.Step()
|
||||
game.alive = false
|
||||
blocked.failSet = true
|
||||
newHeavy := f.add(40, 150, PriorityClassNormal)
|
||||
c.Step()
|
||||
assertPriority(t, healthy, PriorityClassBelowNormal)
|
||||
assertPriority(t, newHeavy, PriorityClassHigh)
|
||||
blocked.failSet = false
|
||||
r := c.Shutdown()
|
||||
if r.Unrestored != 1 {
|
||||
t.Fatal("blocked restoration incorrectly reported successful", r)
|
||||
}
|
||||
assertPriority(t, blocked, PriorityClassIdle)
|
||||
assertPriority(t, newHeavy, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOrdinaryPolicyUsesFreshPriorityAndReleasesHandles(t *testing.T) {
|
||||
t.Run("do not raise a light process after an external demotion", func(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 50, PriorityClassHigh)
|
||||
c := testController(f)
|
||||
defer c.Shutdown()
|
||||
f.onRead = func(v *fakeProcess) { v.info.Prio = PriorityClassBelowNormal; f.onRead = nil }
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
if p.writes != 0 {
|
||||
t.Fatal("stale priority decision performed a write")
|
||||
}
|
||||
})
|
||||
t.Run("release obsolete denied-write state", func(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
p.writable = false
|
||||
c := testController(f)
|
||||
defer c.Shutdown()
|
||||
c.Step()
|
||||
p.info.RSS = 50
|
||||
c.Step()
|
||||
assertClosed(t, f)
|
||||
})
|
||||
}
|
||||
func TestDeniedHeavyTriggerIsStillLoggedBeforeBackground(t *testing.T) {
|
||||
f := newFake()
|
||||
g := f.add(10, 250, PriorityClassNormal)
|
||||
g.info.Name = "BlockedGame.exe"
|
||||
g.writable = false
|
||||
b := f.add(20, 50, PriorityClassNormal)
|
||||
b.info.Name = "Background.exe"
|
||||
c := testController(f)
|
||||
defer c.Shutdown()
|
||||
text := formatReport(c.Step(), time.Unix(0, 0))
|
||||
if strings.Index(text, "BlockedGame.exe") < 0 || strings.Index(text, "BlockedGame.exe") > strings.Index(text, "Background.exe") {
|
||||
t.Fatal("the triggering process is hidden below background changes", text)
|
||||
}
|
||||
if strings.Contains(text, "NORMAL -> HIGH") {
|
||||
t.Fatal("denied raise was logged as a success")
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func cleanLogText(s string, limit int) string {
|
||||
var out []rune
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) || unicode.In(r, unicode.Cf) || r == '|' {
|
||||
r = ' '
|
||||
}
|
||||
if len(out) >= limit {
|
||||
if limit >= 3 {
|
||||
out = append(out[:limit-3], '.', '.', '.')
|
||||
}
|
||||
break
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
func priorityRank(p uint32) int {
|
||||
switch p {
|
||||
case PriorityClassIdle:
|
||||
return 0
|
||||
case PriorityClassBelowNormal:
|
||||
return 1
|
||||
case PriorityClassNormal:
|
||||
return 2
|
||||
case PriorityClassAboveNormal:
|
||||
return 3
|
||||
case PriorityClassHigh:
|
||||
return 4
|
||||
case PriorityClassRealtime:
|
||||
return 5
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func logMemory(b uint64) string {
|
||||
if b >= 1<<30 {
|
||||
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
|
||||
}
|
||||
if b >= 1<<20 {
|
||||
return fmt.Sprintf("%d МБ", b>>20)
|
||||
}
|
||||
if b >= 1<<10 {
|
||||
return fmt.Sprintf("%d КБ", b>>10)
|
||||
}
|
||||
return fmt.Sprintf("%d Б", b)
|
||||
}
|
||||
func writeLogTable(b *strings.Builder, cells [][]string) {
|
||||
widths := make([]int, len(cells[0]))
|
||||
for _, row := range cells {
|
||||
for i, value := range row {
|
||||
widths[i] = max(widths[i], utf8.RuneCountInString(value))
|
||||
}
|
||||
}
|
||||
var border strings.Builder
|
||||
border.WriteByte('+')
|
||||
for _, width := range widths {
|
||||
border.WriteString(strings.Repeat("-", width+2))
|
||||
border.WriteByte('+')
|
||||
}
|
||||
border.WriteByte('\n')
|
||||
b.WriteString(border.String())
|
||||
for n, row := range cells {
|
||||
b.WriteByte('|')
|
||||
for i, value := range row {
|
||||
fmt.Fprintf(b, " %-*s |", widths[i], value)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
if n == 0 {
|
||||
b.WriteString(border.String())
|
||||
}
|
||||
}
|
||||
b.WriteString(border.String())
|
||||
}
|
||||
func formatReport(r scanReport, when time.Time) string {
|
||||
if r.Title == "" && len(r.Rows) == 0 && len(r.Errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
header := func(title string) {
|
||||
fmt.Fprintf(&b, "[%s] %s", when.Format("02.01.2006 15:04:05"), cleanLogText(title, 200))
|
||||
if r.DryRun {
|
||||
b.WriteString(" [DRY-RUN: ПЛАН, БЕЗ ИЗМЕНЕНИЙ]")
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if r.Title != "" || len(r.Rows) > 0 {
|
||||
title := r.Title
|
||||
if title == "" {
|
||||
title = "ИЗМЕНЕНИЕ ПРИОРИТЕТОВ"
|
||||
}
|
||||
header(title)
|
||||
if len(r.Rows) > 0 {
|
||||
rows := append([]priorityChange(nil), r.Rows...)
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Heavy != rows[j].Heavy {
|
||||
return rows[i].Heavy
|
||||
}
|
||||
return rows[i].PID < rows[j].PID
|
||||
})
|
||||
cells := [][]string{{"Процесс", "PID", "Память", "Повышено", "Понижено"}}
|
||||
for _, row := range rows {
|
||||
columns := []string{cleanLogText(row.Name, 64), strconv.FormatUint(uint64(row.PID), 10), logMemory(row.RSS), "", ""}
|
||||
if row.Blocked {
|
||||
columns[3] = "в блоклисте"
|
||||
} else if row.Unchanged {
|
||||
columns[3] = "уже " + prioName(row.After)
|
||||
} else {
|
||||
change := prioName(row.Before) + " -> " + prioName(row.After)
|
||||
if priorityRank(row.After) > priorityRank(row.Before) {
|
||||
columns[3] = change
|
||||
} else {
|
||||
columns[4] = change
|
||||
}
|
||||
}
|
||||
cells = append(cells, columns)
|
||||
}
|
||||
writeLogTable(&b, cells)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
header("ОШИБКИ")
|
||||
rows := append([]processError(nil), r.Errors...)
|
||||
sort.SliceStable(rows, func(i, j int) bool { return rows[i].PID < rows[j].PID })
|
||||
cells := [][]string{{"Процесс", "PID", "Описание ошибки"}}
|
||||
for _, problem := range rows {
|
||||
name, pid := problem.Name, "-"
|
||||
if name == "" {
|
||||
name = "autoPriority"
|
||||
}
|
||||
if problem.PID != 0 {
|
||||
pid = strconv.FormatUint(uint64(problem.PID), 10)
|
||||
}
|
||||
cells = append(cells, []string{cleanLogText(name, 64), pid, cleanLogText(problem.Description, 1500)})
|
||||
}
|
||||
writeLogTable(&b, cells)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestReportTableHeavyFirstAndTruePriorityDirections(t *testing.T) {
|
||||
r := scanReport{Title: "ИГРОВОЙ РЕЖИМ ВКЛЮЧЁН", Rows: []priorityChange{{Name: "Background.exe", PID: 20, RSS: 500, Before: PriorityClassNormal, After: PriorityClassIdle}, {Name: "Game.exe", PID: 10, RSS: 3000000000, Before: PriorityClassNormal, After: PriorityClassHigh, Heavy: true}}}
|
||||
text := formatReport(r, time.Unix(0, 0))
|
||||
if !strings.Contains(text, "Повышено") || !strings.Contains(text, "Понижено") || !strings.Contains(text, "+---") {
|
||||
t.Fatalf("missing ASCII table: %q", text)
|
||||
}
|
||||
if strings.Index(text, "Game.exe") > strings.Index(text, "Background.exe") {
|
||||
t.Fatal("heavy process must precede lowered processes")
|
||||
}
|
||||
var gameLine, bgLine string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.Contains(line, "Game.exe") {
|
||||
gameLine = line
|
||||
}
|
||||
if strings.Contains(line, "Background.exe") {
|
||||
bgLine = line
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Split(gameLine, "|")[4], "NORMAL -> HIGH") {
|
||||
t.Fatal("raise in wrong column", gameLine)
|
||||
}
|
||||
if !strings.Contains(strings.Split(bgLine, "|")[5], "NORMAL -> IDLE") {
|
||||
t.Fatal("numeric Win32 constants mistaken for scheduling rank", bgLine)
|
||||
}
|
||||
if r.Rows[0].Name != "Background.exe" {
|
||||
t.Fatal("formatter mutated caller's report")
|
||||
}
|
||||
}
|
||||
func TestReportNoFakePromotionNoControlCharacters(t *testing.T) {
|
||||
r := scanReport{Title: "MODE", DryRun: true, Rows: []priorityChange{{Name: "bad\r\n\x1b|name.exe", PID: 1, Before: PriorityClassHigh, After: PriorityClassHigh, Heavy: true, Unchanged: true}}, Errors: []processError{{Description: "denied\r\nforged"}}}
|
||||
s := formatReport(r, time.Unix(0, 0))
|
||||
if strings.Contains(s, "HIGH -> HIGH") || strings.ContainsAny(s, "\r\x1b") {
|
||||
t.Fatalf("misleading or unsafe record: %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "уже HIGH") || !strings.Contains(s, "DRY-RUN") {
|
||||
t.Fatal("missing unchanged/dry-run markers")
|
||||
}
|
||||
if !utf8.ValidString(s) {
|
||||
t.Fatal("invalid UTF-8")
|
||||
}
|
||||
}
|
||||
func TestEmptyReportProducesNoLog(t *testing.T) {
|
||||
if got := formatReport(scanReport{}, time.Time{}); got != "" {
|
||||
t.Fatalf("noisy empty report %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,633 +3,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateToolhelp32SnapshotProcess = 0x00000002
|
||||
ProcessSetInformation = 0x00000200
|
||||
ProcessQueryLimitedInformation = 0x00001000
|
||||
PriorityClassIdle = 0x00000040
|
||||
PriorityClassNormal = 0x00000020
|
||||
PriorityClassHigh = 0x00000080
|
||||
PriorityClassAboveNormal = 0x00008000
|
||||
PriorityClassRealtime = 0x00000100
|
||||
PriorityClassBelowNormal = 0x00004000
|
||||
ErrorNoMoreFiles = 18
|
||||
ErrorAlreadyExists = 183
|
||||
)
|
||||
|
||||
type processEntry32 struct {
|
||||
Size uint32
|
||||
CntUsage uint32
|
||||
PID uint32
|
||||
DefaultHeapID uintptr
|
||||
ModuleID uint32
|
||||
CntThreads uint32
|
||||
ParentPID uint32
|
||||
PrioClass int32
|
||||
Flags uint32
|
||||
ExeFile [260]uint16
|
||||
type options struct {
|
||||
mem, gameMem uint64
|
||||
interval time.Duration
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
type processMemoryCounters struct {
|
||||
CBM uint32
|
||||
PageFaultCount uint32
|
||||
PeakWorkingSetSize uintptr
|
||||
WorkingSetSize uintptr
|
||||
QuotaPeakPagedPoolUsage uintptr
|
||||
QuotaPagedPoolUsage uintptr
|
||||
QuotaPeakNonPagedPoolUsage uintptr
|
||||
QuotaNonPagedPoolUsage uintptr
|
||||
PeakPagefileUsage uintptr
|
||||
PagefileUsage uintptr
|
||||
PrivateUsage uintptr
|
||||
}
|
||||
|
||||
type processKey struct {
|
||||
PID uint32
|
||||
Created uint64
|
||||
}
|
||||
|
||||
type procInfo struct {
|
||||
PID uint32
|
||||
Name string
|
||||
RSS uint64
|
||||
Key processKey
|
||||
Prio uint32
|
||||
Handle syscall.Handle
|
||||
CanSet bool
|
||||
Keep bool
|
||||
}
|
||||
|
||||
type trackedProc struct {
|
||||
name string
|
||||
handle syscall.Handle
|
||||
}
|
||||
|
||||
type savedPrio struct {
|
||||
name string
|
||||
prio uint32
|
||||
handle syscall.Handle
|
||||
}
|
||||
|
||||
var (
|
||||
k32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
ps = syscall.NewLazyDLL("psapi.dll")
|
||||
|
||||
procCreateSnap = k32.NewProc("CreateToolhelp32Snapshot")
|
||||
procProcess32First = k32.NewProc("Process32FirstW")
|
||||
procProcess32Next = k32.NewProc("Process32NextW")
|
||||
procGetMemInfo = ps.NewProc("GetProcessMemoryInfo")
|
||||
procSetPriority = k32.NewProc("SetPriorityClass")
|
||||
procGetPriority = k32.NewProc("GetPriorityClass")
|
||||
procOpenProcess = k32.NewProc("OpenProcess")
|
||||
procCloseHandle = k32.NewProc("CloseHandle")
|
||||
procGetProcessTimes = k32.NewProc("GetProcessTimes")
|
||||
procQueryImageName = k32.NewProc("QueryFullProcessImageNameW")
|
||||
procCreateMutex = k32.NewProc("CreateMutexW")
|
||||
)
|
||||
|
||||
func closeH(h syscall.Handle) {
|
||||
_, _, _ = procCloseHandle.Call(uintptr(h))
|
||||
}
|
||||
|
||||
func openProc(pid uint32, acc uint32) (syscall.Handle, error) {
|
||||
r, _, e := procOpenProcess.Call(uintptr(acc), 0, uintptr(pid))
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("OpenProcess(%d) failed: %w", pid, e)
|
||||
func readOptions(args []string, output io.Writer) (options, error) {
|
||||
var o options
|
||||
fs := flag.NewFlagSet("autoPriority", flag.ContinueOnError)
|
||||
fs.SetOutput(output)
|
||||
mem := fs.String("mem", "512M", "memory threshold (e.g. 512M, 1G)")
|
||||
gameMem := fs.String("game-mem", "2G", "heavy-process threshold; greater than -mem, or 0 to disable")
|
||||
fs.DurationVar(&o.interval, "interval", time.Minute, "scan interval (minimum 10s)")
|
||||
fs.BoolVar(&o.dryRun, "dry-run", false, "log the plan without changing priorities")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return o, err
|
||||
}
|
||||
return syscall.Handle(r), nil
|
||||
}
|
||||
|
||||
func setPrio(h syscall.Handle, pid uint32, cls uint32) error {
|
||||
r, _, e := procSetPriority.Call(uintptr(h), uintptr(cls))
|
||||
if r == 0 {
|
||||
return fmt.Errorf("SetPriorityClass(%d) failed: %w", pid, e)
|
||||
if fs.NArg() != 0 {
|
||||
return o, fmt.Errorf("unexpected positional arguments")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processCreated(h syscall.Handle) (uint64, error) {
|
||||
var created, exited, kernel, user syscall.Filetime
|
||||
r, _, e := procGetProcessTimes.Call(
|
||||
uintptr(h),
|
||||
uintptr(unsafe.Pointer(&created)),
|
||||
uintptr(unsafe.Pointer(&exited)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("GetProcessTimes failed: %w", e)
|
||||
var err error
|
||||
o.mem, err = parseMemSize(*mem)
|
||||
if err != nil || o.mem == 0 {
|
||||
return o, fmt.Errorf("invalid -mem: use a positive integer with optional K/M/G suffix")
|
||||
}
|
||||
return uint64(created.HighDateTime)<<32 | uint64(created.LowDateTime), nil
|
||||
}
|
||||
|
||||
func processName(h syscall.Handle) (string, error) {
|
||||
buf := make([]uint16, 32768)
|
||||
size := uint32(len(buf))
|
||||
r, _, e := procQueryImageName.Call(uintptr(h), 0, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)))
|
||||
if r == 0 {
|
||||
return "", fmt.Errorf("QueryFullProcessImageName failed: %w", e)
|
||||
}
|
||||
return filepath.Base(syscall.UTF16ToString(buf[:size])), nil
|
||||
}
|
||||
|
||||
func processAlive(h syscall.Handle) (bool, error) {
|
||||
var created, exited, kernel, user syscall.Filetime
|
||||
r, _, e := procGetProcessTimes.Call(
|
||||
uintptr(h),
|
||||
uintptr(unsafe.Pointer(&created)),
|
||||
uintptr(unsafe.Pointer(&exited)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r == 0 {
|
||||
return false, fmt.Errorf("GetProcessTimes failed: %w", e)
|
||||
}
|
||||
return exited.HighDateTime == 0 && exited.LowDateTime == 0, nil
|
||||
}
|
||||
|
||||
func parseMemSize(s string) (uint64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty value")
|
||||
}
|
||||
multiplier := uint64(1)
|
||||
switch s[len(s)-1] {
|
||||
case 'k', 'K':
|
||||
multiplier = 1024
|
||||
s = s[:len(s)-1]
|
||||
case 'm', 'M':
|
||||
multiplier = 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
case 'g', 'G':
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
o.gameMem, err = parseMemSize(*gameMem)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return o, fmt.Errorf("invalid -game-mem: %w", err)
|
||||
}
|
||||
if n > math.MaxUint64/multiplier {
|
||||
return 0, fmt.Errorf("value overflows uint64")
|
||||
if o.gameMem != 0 && o.gameMem <= o.mem {
|
||||
return o, fmt.Errorf("-game-mem must be greater than -mem, or 0")
|
||||
}
|
||||
return n * multiplier, nil
|
||||
if o.interval < 10*time.Second {
|
||||
o.interval = 10 * time.Second
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func formatMemSize(b uint64) string {
|
||||
if b >= 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.1fGB", float64(b)/(1024*1024*1024))
|
||||
func runController(ctx context.Context, o options, api processBackend, emit func(scanReport) error) (result error) {
|
||||
c := newController(api, o.mem, o.gameMem, o.dryRun, o.interval)
|
||||
defer func() {
|
||||
report := c.Shutdown()
|
||||
result = errors.Join(result, emit(report))
|
||||
if report.Unrestored > 0 {
|
||||
result = errors.Join(result, fmt.Errorf("не восстановлены приоритеты %d процессов; см. журнал", report.Unrestored))
|
||||
}
|
||||
if b >= 1024*1024 {
|
||||
return fmt.Sprintf("%dMB", b/1024/1024)
|
||||
}
|
||||
return fmt.Sprintf("%dKB", b/1024)
|
||||
}
|
||||
|
||||
func isAboveNormal(c uint32) bool {
|
||||
return c == PriorityClassAboveNormal ||
|
||||
c == PriorityClassHigh ||
|
||||
c == PriorityClassRealtime
|
||||
}
|
||||
|
||||
func desiredPriority(rss, mem uint64, current uint32, gameMode, isGame bool) uint32 {
|
||||
if gameMode {
|
||||
if isGame {
|
||||
return PriorityClassHigh
|
||||
}
|
||||
return PriorityClassIdle
|
||||
}
|
||||
if rss >= mem {
|
||||
return PriorityClassHigh
|
||||
}
|
||||
if isAboveNormal(current) {
|
||||
return PriorityClassNormal
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func prioName(c uint32) string {
|
||||
switch c {
|
||||
case PriorityClassIdle:
|
||||
return "IDLE"
|
||||
case PriorityClassBelowNormal:
|
||||
return "BELOW_NORMAL"
|
||||
case PriorityClassNormal:
|
||||
return "NORMAL"
|
||||
case PriorityClassAboveNormal:
|
||||
return "ABOVE_NORMAL"
|
||||
case PriorityClassHigh:
|
||||
return "HIGH"
|
||||
case PriorityClassRealtime:
|
||||
return "REALTIME"
|
||||
default:
|
||||
return fmt.Sprintf("0x%X", c)
|
||||
}
|
||||
}
|
||||
|
||||
func allProcs() ([]procInfo, error) {
|
||||
snap, _, e := procCreateSnap.Call(CreateToolhelp32SnapshotProcess, 0)
|
||||
if snap == ^uintptr(0) {
|
||||
return nil, fmt.Errorf("CreateToolhelp32Snapshot failed: %w", e)
|
||||
}
|
||||
defer closeH(syscall.Handle(snap))
|
||||
|
||||
pe := processEntry32{Size: uint32(unsafe.Sizeof(processEntry32{}))}
|
||||
r, _, e := procProcess32First.Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("Process32First failed: %w", e)
|
||||
}
|
||||
|
||||
var out []procInfo
|
||||
}()
|
||||
ticker := time.NewTicker(o.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
name := syscall.UTF16ToString(pe.ExeFile[:])
|
||||
if name != "" {
|
||||
out = append(out, procInfo{PID: pe.PID, Name: name})
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
pe.Size = uint32(unsafe.Sizeof(processEntry32{}))
|
||||
r, _, e = procProcess32Next.Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||
if r == 0 {
|
||||
if e == syscall.Errno(ErrorNoMoreFiles) {
|
||||
break
|
||||
if err := emit(c.Step()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil, fmt.Errorf("Process32Next failed: %w", e)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func singleInstance() (syscall.Handle, bool, error) {
|
||||
name, err := syscall.UTF16PtrFromString(`Local\autoPriority`)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
r, _, e := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
|
||||
if r == 0 {
|
||||
return 0, false, fmt.Errorf("CreateMutex failed: %w", e)
|
||||
}
|
||||
return syscall.Handle(r), e == syscall.Errno(ErrorAlreadyExists), nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
memStr := flag.String("mem", "512M", "memory threshold (e.g. 512M, 1G, 2048M)")
|
||||
gameMemStr := flag.String("game-mem", "2G", "game memory threshold (e.g. 2G, 4G). Must be greater than -mem. 0 = disabled.")
|
||||
interval := flag.Duration("interval", time.Minute, "scan interval")
|
||||
dryRun := flag.Bool("dry-run", false, "log only, do not change priorities")
|
||||
flag.Parse()
|
||||
|
||||
if *interval < 10*time.Second {
|
||||
*interval = 10 * time.Second
|
||||
}
|
||||
|
||||
mem, err := parseMemSize(*memStr)
|
||||
if err != nil || mem == 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("must be greater than zero")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: invalid -mem value: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gameMem, err := parseMemSize(*gameMemStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: invalid -game-mem value: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if gameMem > 0 && gameMem <= mem {
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: -game-mem must be greater than -mem\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func runApplication(o options) (result error) {
|
||||
mutex, exists, err := singleInstance()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if exists {
|
||||
closeH(mutex)
|
||||
fmt.Fprintln(os.Stderr, "autoPriority: already running")
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer closeH(mutex)
|
||||
|
||||
logPath := filepath.Join(os.TempDir(), "autopriority.log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if exists {
|
||||
fmt.Fprintln(os.Stderr, "autoPriority: already running")
|
||||
return nil
|
||||
}
|
||||
logFile, err := openRotatingLog(filepath.Join(os.TempDir(), "autopriority.log"), 4<<20)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: cannot create log: %v\n", err)
|
||||
return err
|
||||
}
|
||||
defer func() { result = errors.Join(result, logFile.Close()) }()
|
||||
emit := func(r scanReport) error { return logFile.Write(formatReport(r, time.Now())) }
|
||||
if err = emit(scanReport{Title: fmt.Sprintf("autoPriority: mem=%s, game-mem=%s, interval=%s", formatMemSize(o.mem), formatMemSize(o.gameMem), o.interval), DryRun: o.dryRun}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !o.dryRun {
|
||||
pid := uint32(os.Getpid())
|
||||
h, e := openProc(pid, ProcessSetInformation)
|
||||
if e == nil {
|
||||
e = setPrio(h, pid, PriorityClassIdle)
|
||||
closeH(h)
|
||||
}
|
||||
if e != nil {
|
||||
if err = emit(scanReport{Errors: []processError{{Name: "autoPriority.exe", PID: pid, Description: fmt.Sprintf("Собственный приоритет IDLE не установлен: %v", e)}}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
restored := make(chan struct{})
|
||||
tray, err := newTray(cancel, restored)
|
||||
if err != nil {
|
||||
close(restored)
|
||||
return err
|
||||
}
|
||||
defer func() { result = errors.Join(result, tray.Close()) }()
|
||||
// runController performs restoration before this channel closes and before the tray disappears.
|
||||
defer close(restored)
|
||||
return runController(ctx, o, newWindowsBackend(), emit)
|
||||
}
|
||||
func main() {
|
||||
o, err := readOptions(os.Args[1:], os.Stderr)
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
err = runApplication(o)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoPriority: %v\n", err)
|
||||
showError(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
fmt.Fprintf(logFile, "[%s] ", time.Now().Format("02.01.2006 15:04:05"))
|
||||
fmt.Fprintf(logFile, format+"\n", a...)
|
||||
}
|
||||
|
||||
logf("autoPriority started (mem=%s, game-mem=%s, interval=%s, dry-run=%v)",
|
||||
formatMemSize(mem), formatMemSize(gameMem), *interval, *dryRun)
|
||||
|
||||
if !*dryRun {
|
||||
pid := uint32(os.Getpid())
|
||||
h, err := openProc(pid, ProcessSetInformation)
|
||||
if err == nil {
|
||||
err = setPrio(h, pid, PriorityClassIdle)
|
||||
closeH(h)
|
||||
}
|
||||
if err != nil {
|
||||
logf("warning: could not set own priority to IDLE: %v", err)
|
||||
} else {
|
||||
logf("own priority set to IDLE")
|
||||
}
|
||||
}
|
||||
|
||||
blocked := make(map[processKey]string)
|
||||
unreadable := make(map[uint32]string)
|
||||
gameProcs := make(map[processKey]trackedProc)
|
||||
gameSaved := make(map[processKey]savedPrio)
|
||||
gameMode := false
|
||||
myPID := uint32(os.Getpid())
|
||||
|
||||
readProcs := func() ([]procInfo, error) {
|
||||
procs, err := allProcs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
live := make(map[uint32]string, len(procs))
|
||||
list := make([]procInfo, 0, len(procs))
|
||||
for i := range procs {
|
||||
p := &procs[i]
|
||||
live[p.PID] = p.Name
|
||||
if p.PID == myPID || p.PID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
h, setErr := openProc(p.PID, ProcessQueryLimitedInformation|ProcessSetInformation)
|
||||
p.CanSet = setErr == nil
|
||||
openErr := error(nil)
|
||||
if !p.CanSet {
|
||||
h, openErr = openProc(p.PID, ProcessQueryLimitedInformation)
|
||||
}
|
||||
if openErr != nil {
|
||||
if unreadable[p.PID] != p.Name {
|
||||
unreadable[p.PID] = p.Name
|
||||
logf("SKIP %s (PID %d): %v", p.Name, p.PID, openErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
name, readErr := processName(h)
|
||||
if readErr == nil {
|
||||
p.Name = name
|
||||
}
|
||||
created := uint64(0)
|
||||
if readErr == nil {
|
||||
created, readErr = processCreated(h)
|
||||
}
|
||||
var m processMemoryCounters
|
||||
if readErr == nil {
|
||||
m.CBM = uint32(unsafe.Sizeof(m))
|
||||
r, _, e := procGetMemInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&m)), uintptr(unsafe.Sizeof(m)))
|
||||
if r == 0 {
|
||||
readErr = fmt.Errorf("GetProcessMemoryInfo failed: %w", e)
|
||||
}
|
||||
}
|
||||
var cur uintptr
|
||||
if readErr == nil {
|
||||
r, _, e := procGetPriority.Call(uintptr(h))
|
||||
cur = r
|
||||
if cur == 0 {
|
||||
readErr = fmt.Errorf("GetPriorityClass failed: %w", e)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
closeH(h)
|
||||
if unreadable[p.PID] != p.Name {
|
||||
unreadable[p.PID] = p.Name
|
||||
logf("SKIP %s (PID %d): %v", p.Name, p.PID, readErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
delete(unreadable, p.PID)
|
||||
p.Key = processKey{PID: p.PID, Created: created}
|
||||
p.RSS = uint64(m.WorkingSetSize)
|
||||
p.Prio = uint32(cur)
|
||||
p.Handle = h
|
||||
if !p.CanSet {
|
||||
if _, ok := blocked[p.Key]; !ok {
|
||||
blocked[p.Key] = p.Name
|
||||
logf("BLOCK %s (PID %d): %v (priority changes disabled)", p.Name, p.PID, setErr)
|
||||
}
|
||||
}
|
||||
list = append(list, *p)
|
||||
}
|
||||
|
||||
for pid, name := range unreadable {
|
||||
if live[pid] != name {
|
||||
delete(unreadable, pid)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
restore := func(list []procInfo) {
|
||||
current := make(map[processKey]*procInfo, len(list))
|
||||
for i := range list {
|
||||
current[list[i].Key] = &list[i]
|
||||
}
|
||||
for key, saved := range gameSaved {
|
||||
alive, err := processAlive(saved.handle)
|
||||
if err != nil {
|
||||
logf("RESTORE %s (PID %d) status error: %v", saved.name, key.PID, err)
|
||||
continue
|
||||
}
|
||||
if !alive {
|
||||
closeH(saved.handle)
|
||||
delete(gameSaved, key)
|
||||
continue
|
||||
}
|
||||
cur, _, e := procGetPriority.Call(uintptr(saved.handle))
|
||||
if cur == 0 {
|
||||
logf("RESTORE %s (PID %d) priority error: %v", saved.name, key.PID, e)
|
||||
continue
|
||||
}
|
||||
if uint32(cur) != saved.prio {
|
||||
if err := setPrio(saved.handle, key.PID, saved.prio); err != nil {
|
||||
logf("RESTORE %s (PID %d) -> %s error: %v", saved.name, key.PID, prioName(saved.prio), err)
|
||||
continue
|
||||
}
|
||||
logf("RESTORE %s (PID %d) -> %s", saved.name, key.PID, prioName(saved.prio))
|
||||
}
|
||||
if p := current[key]; p != nil {
|
||||
p.Prio = saved.prio
|
||||
}
|
||||
closeH(saved.handle)
|
||||
delete(gameSaved, key)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for attempt := 0; attempt < 3 && len(gameSaved) > 0; attempt++ {
|
||||
restore(nil)
|
||||
if len(gameSaved) > 0 {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if len(gameSaved) > 0 {
|
||||
logf("warning: %d process priorities could not be restored", len(gameSaved))
|
||||
for _, saved := range gameSaved {
|
||||
closeH(saved.handle)
|
||||
}
|
||||
}
|
||||
for _, game := range gameProcs {
|
||||
closeH(game.handle)
|
||||
}
|
||||
logf("autoPriority stopped")
|
||||
logFile.Sync()
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(*interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
scan := func() {
|
||||
list, err := readProcs()
|
||||
if err != nil {
|
||||
logf("process scan error: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
for _, p := range list {
|
||||
if !p.Keep {
|
||||
closeH(p.Handle)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
current := make(map[processKey]string, len(list))
|
||||
for _, p := range list {
|
||||
current[p.Key] = p.Name
|
||||
}
|
||||
|
||||
for key, name := range blocked {
|
||||
if current[key] != name {
|
||||
delete(blocked, key)
|
||||
}
|
||||
}
|
||||
for key, game := range gameProcs {
|
||||
alive, err := processAlive(game.handle)
|
||||
if err != nil {
|
||||
logf("GAME %s (PID %d) status error: %v", game.name, key.PID, err)
|
||||
continue
|
||||
}
|
||||
if !alive {
|
||||
closeH(game.handle)
|
||||
delete(gameProcs, key)
|
||||
}
|
||||
}
|
||||
for key, saved := range gameSaved {
|
||||
alive, err := processAlive(saved.handle)
|
||||
if err == nil && !alive {
|
||||
closeH(saved.handle)
|
||||
delete(gameSaved, key)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range list {
|
||||
p := &list[i]
|
||||
if gameMem > 0 && p.RSS >= gameMem {
|
||||
if _, ok := gameProcs[p.Key]; !ok {
|
||||
gameProcs[p.Key] = trackedProc{name: p.Name, handle: p.Handle}
|
||||
p.Keep = true
|
||||
logf("GAME DETECT %s (PID %d) RSS=%s", p.Name, p.PID, formatMemSize(p.RSS))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasGame := gameMem > 0 && len(gameProcs) > 0
|
||||
enteringGame := hasGame && !gameMode
|
||||
if enteringGame {
|
||||
gameMode = true
|
||||
}
|
||||
if !hasGame && gameMode {
|
||||
restore(list)
|
||||
if len(gameSaved) == 0 {
|
||||
gameMode = false
|
||||
logf("GAME MODE OFF")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasGame {
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
_, iGame := gameProcs[list[i].Key]
|
||||
_, jGame := gameProcs[list[j].Key]
|
||||
return iGame && !jGame
|
||||
})
|
||||
}
|
||||
|
||||
for i := range list {
|
||||
p := &list[i]
|
||||
_, isBlocked := blocked[p.Key]
|
||||
_, isGame := gameProcs[p.Key]
|
||||
target := desiredPriority(p.RSS, mem, p.Prio, hasGame, isGame)
|
||||
action := "GAME"
|
||||
if !hasGame && target == PriorityClassHigh {
|
||||
action = "PROMOTE"
|
||||
} else if !hasGame {
|
||||
action = "DEMOTE"
|
||||
}
|
||||
|
||||
if target == 0 || target == p.Prio || isBlocked {
|
||||
continue
|
||||
}
|
||||
if *dryRun {
|
||||
logf("[DRY-RUN] %s %s (PID %d) RSS=%s, %s -> %s", action, p.Name, p.PID, formatMemSize(p.RSS), prioName(p.Prio), prioName(target))
|
||||
continue
|
||||
}
|
||||
if err := setPrio(p.Handle, p.PID, target); err != nil {
|
||||
blocked[p.Key] = p.Name
|
||||
logf("BLOCK %s (PID %d): %v (priority changes disabled)", p.Name, p.PID, err)
|
||||
continue
|
||||
}
|
||||
if hasGame && !isGame && target == PriorityClassIdle {
|
||||
if _, saved := gameSaved[p.Key]; !saved {
|
||||
gameSaved[p.Key] = savedPrio{name: p.Name, prio: p.Prio, handle: p.Handle}
|
||||
p.Keep = true
|
||||
}
|
||||
}
|
||||
logf("%s %s (PID %d) RSS=%s, %s -> %s", action, p.Name, p.PID, formatMemSize(p.RSS), prioName(p.Prio), prioName(target))
|
||||
p.Prio = target
|
||||
}
|
||||
if enteringGame {
|
||||
logf("GAME MODE ON")
|
||||
}
|
||||
}
|
||||
|
||||
scan()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
logf("received shutdown signal")
|
||||
return
|
||||
case <-ticker.C:
|
||||
scan()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
PriorityClassIdle = 0x00000040
|
||||
PriorityClassNormal = 0x00000020
|
||||
PriorityClassHigh = 0x00000080
|
||||
PriorityClassAboveNormal = 0x00008000
|
||||
PriorityClassRealtime = 0x00000100
|
||||
PriorityClassBelowNormal = 0x00004000
|
||||
)
|
||||
|
||||
func parseMemSize(s string) (uint64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty value")
|
||||
}
|
||||
multiplier := uint64(1)
|
||||
switch s[len(s)-1] {
|
||||
case 'k', 'K':
|
||||
multiplier = 1024
|
||||
s = s[:len(s)-1]
|
||||
case 'm', 'M':
|
||||
multiplier = 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
case 'g', 'G':
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > math.MaxUint64/multiplier {
|
||||
return 0, fmt.Errorf("value overflows uint64")
|
||||
}
|
||||
return n * multiplier, nil
|
||||
}
|
||||
|
||||
func formatMemSize(b uint64) string {
|
||||
if b >= 1024*1024*1024 {
|
||||
return fmt.Sprintf("%.1fGB", float64(b)/(1024*1024*1024))
|
||||
}
|
||||
if b >= 1024*1024 {
|
||||
return fmt.Sprintf("%dMB", b/1024/1024)
|
||||
}
|
||||
return fmt.Sprintf("%dKB", b/1024)
|
||||
}
|
||||
|
||||
func isAboveNormal(c uint32) bool {
|
||||
return c == PriorityClassAboveNormal ||
|
||||
c == PriorityClassHigh ||
|
||||
c == PriorityClassRealtime
|
||||
}
|
||||
|
||||
func desiredPriority(rss, mem uint64, current uint32, gameMode, isGame bool) uint32 {
|
||||
if gameMode {
|
||||
if isGame {
|
||||
return PriorityClassHigh
|
||||
}
|
||||
return PriorityClassIdle
|
||||
}
|
||||
if rss >= mem {
|
||||
return PriorityClassHigh
|
||||
}
|
||||
if isAboveNormal(current) {
|
||||
return PriorityClassNormal
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func prioName(c uint32) string {
|
||||
switch c {
|
||||
case PriorityClassIdle:
|
||||
return "IDLE"
|
||||
case PriorityClassBelowNormal:
|
||||
return "BELOW_NORMAL"
|
||||
case PriorityClassNormal:
|
||||
return "NORMAL"
|
||||
case PriorityClassAboveNormal:
|
||||
return "ABOVE_NORMAL"
|
||||
case PriorityClassHigh:
|
||||
return "HIGH"
|
||||
case PriorityClassRealtime:
|
||||
return "REALTIME"
|
||||
default:
|
||||
return fmt.Sprintf("0x%X", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewLaunchClearsLogAndPreviousArchive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "autopriority.log")
|
||||
for _, p := range []string{path, path + ".1"} {
|
||||
if err := os.WriteFile(p, []byte("PREVIOUS SESSION\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w, err := openRotatingLog(path, 256)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) != 0 {
|
||||
t.Fatal("new session retained the previous log")
|
||||
}
|
||||
if _, err = os.Stat(path + ".1"); !os.IsNotExist(err) {
|
||||
t.Fatal("previous-session archive retained")
|
||||
}
|
||||
if err = w.Write("CURRENT SESSION\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = os.ReadFile(path)
|
||||
if err != nil || string(data) != "CURRENT SESSION\n" {
|
||||
t.Fatal("wrong current-session log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedProcessNotRetriedUntilNewInstance(t *testing.T) {
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
p.writable = false
|
||||
c := testController(f)
|
||||
defer c.Shutdown()
|
||||
if r := c.Step(); len(r.Errors) != 1 {
|
||||
t.Fatal("missing initial denial")
|
||||
}
|
||||
p.writable = true
|
||||
c.now = func() time.Time { return time.Unix(100000, 0) }
|
||||
if r := c.Step(); len(r.Errors) != 0 {
|
||||
t.Fatal("repeated denial")
|
||||
}
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
p.info.Prio = PriorityClassBelowNormal
|
||||
p.info.RSS = 250
|
||||
c.Step()
|
||||
assertPriority(t, p, PriorityClassBelowNormal)
|
||||
if p.writes != 0 {
|
||||
t.Fatal("blocked instance was written again")
|
||||
}
|
||||
p.alive = false
|
||||
replacement := f.add(10, 150, PriorityClassNormal)
|
||||
delete(f.procs, replacement.info.Key)
|
||||
replacement.info.Key.Created++
|
||||
f.procs[replacement.info.Key] = replacement
|
||||
c.Step()
|
||||
assertPriority(t, replacement, PriorityClassHigh)
|
||||
c.Shutdown()
|
||||
assertPriority(t, replacement, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
}
|
||||
|
||||
func TestErrorsAreOneSeparateTableWithoutRetryLines(t *testing.T) {
|
||||
f := newFake()
|
||||
a := f.add(4, 150, PriorityClassNormal)
|
||||
a.info.Name = "System"
|
||||
a.writable = false
|
||||
b := f.add(148, 150, PriorityClassNormal)
|
||||
b.info.Name = "Registry"
|
||||
b.writable = false
|
||||
c := testController(f)
|
||||
defer c.Shutdown()
|
||||
text := formatReport(c.Step(), time.Unix(0, 0))
|
||||
if !strings.Contains(text, "Описание ошибки") || strings.Contains(text, "ОШИБКА:") || strings.Contains(text, "повтор") {
|
||||
t.Fatal("errors must be a table without retry lines", text)
|
||||
}
|
||||
for _, name := range []string{"System", "Registry"} {
|
||||
if strings.Count(text, name) != 1 {
|
||||
t.Fatal("duplicated or missing process", name, text)
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.Contains(line, name) && !strings.HasPrefix(line, "|") {
|
||||
t.Fatal("process outside error table", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const truncatedLogRecord = "\n[Слишком длинная запись сокращена]\n"
|
||||
|
||||
type rotatingLog struct {
|
||||
file *os.File
|
||||
path string
|
||||
limit, size int64
|
||||
}
|
||||
|
||||
func openRotatingLog(path string, limit int64) (*rotatingLog, error) {
|
||||
if limit < int64(len(truncatedLogRecord)+16) {
|
||||
return nil, fmt.Errorf("log size limit is too small")
|
||||
}
|
||||
for _, name := range []string{path, path + ".1"} {
|
||||
info, err := os.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("log target is not a regular file")
|
||||
}
|
||||
if name != path {
|
||||
if err = os.Remove(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rotatingLog{file: f, path: path, limit: limit}, nil
|
||||
}
|
||||
func (w *rotatingLog) Write(text string) error {
|
||||
if w.file == nil {
|
||||
return os.ErrClosed
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
if !utf8.ValidString(text) {
|
||||
text = strings.ToValidUTF8(text, "?")
|
||||
}
|
||||
if int64(len(text)) > w.limit {
|
||||
end := int(w.limit) - len(truncatedLogRecord)
|
||||
for end > 0 && !utf8.ValidString(text[:end]) {
|
||||
end--
|
||||
}
|
||||
text = text[:end] + truncatedLogRecord
|
||||
}
|
||||
if w.size > 0 && w.size+int64(len(text)) > w.limit {
|
||||
if err := w.rotate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
n, err := w.file.WriteString(text)
|
||||
w.size += int64(n)
|
||||
if err == nil && n != len(text) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (w *rotatingLog) rotate() error {
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, e := os.Lstat(w.path + ".1"); e == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("log archive is not a regular file")
|
||||
}
|
||||
if e = os.Remove(w.path + ".1"); e != nil {
|
||||
return e
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return e
|
||||
}
|
||||
if err = os.Rename(w.path, w.path+".1"); err != nil {
|
||||
return err
|
||||
}
|
||||
w.file, err = os.OpenFile(w.path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
w.size = 0
|
||||
return err
|
||||
}
|
||||
func (w *rotatingLog) Close() error {
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
e := errors.Join(w.file.Sync(), w.file.Close())
|
||||
w.file = nil
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestLogRotationBoundedUTF8AndRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.log")
|
||||
w, e := openRotatingLog(path, 256)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if e = w.Write(strings.Repeat("АБ", 40) + "\n"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if e = w.Write(strings.Repeat("Ж", 600)); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = w.Close(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, p := range []string{path, path + ".1"} {
|
||||
data, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(data) > 256 {
|
||||
t.Fatalf("unbounded log: %d bytes", len(data))
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
t.Fatal("split a UTF-8 character")
|
||||
}
|
||||
}
|
||||
w, e = openRotatingLog(path, 256)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = w.Write("LAST\n"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
w.Close()
|
||||
data, e := os.ReadFile(path)
|
||||
if e != nil || !strings.HasSuffix(string(data), "LAST\n") {
|
||||
t.Fatal("restart failed")
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("unexpected files: %d", len(entries))
|
||||
}
|
||||
}
|
||||
func TestLogRejectsDirectoryAndClosedWriter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if w, e := openRotatingLog(dir, 256); e == nil {
|
||||
w.Close()
|
||||
t.Fatal("accepted a directory")
|
||||
}
|
||||
w, e := openRotatingLog(filepath.Join(dir, "x.log"), 256)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
w.Close()
|
||||
if e = w.Write("x"); e == nil {
|
||||
t.Fatal("silenced write after close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed assets/chart_bar_edit.png
|
||||
var trayPNG []byte
|
||||
|
||||
//go:embed assets/cross.png
|
||||
var exitPNG []byte
|
||||
|
||||
func trayActivation(version4 bool, event uint32) bool {
|
||||
if version4 {
|
||||
switch event {
|
||||
case 0x400, 0x401, 0x7b, 0x208, 0x20c:
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
switch event {
|
||||
case 0x202, 0x205, 0x208, 0x20c, 0x400, 0x401, 0x7b:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type guiThreadInfo struct {
|
||||
Size, Flags uint32
|
||||
Active, Focus, Capture, MenuOwner, MoveSize, Caret uintptr
|
||||
CaretRect winRect
|
||||
}
|
||||
|
||||
func waitNative(t *testing.T, condition func() bool, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timeout: " + what)
|
||||
}
|
||||
func postOwned(t *testing.T, h uintptr, msg uint32, wp, lp uintptr) {
|
||||
t.Helper()
|
||||
ok, _, err := pPostMessage.Call(h, uintptr(msg), wp, lp)
|
||||
if ok == 0 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func ownIconRect(h uintptr) (winRect, bool) {
|
||||
id := notifyIconIdentifier{Size: uint32(unsafe.Sizeof(notifyIconIdentifier{})), Window: h, ID: trayIconID}
|
||||
var rect winRect
|
||||
hr, _, _ := pNotifyRect.Call(uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&rect)))
|
||||
return rect, hr == 0
|
||||
}
|
||||
func ownMenuActive(thread uintptr) bool {
|
||||
info := guiThreadInfo{Size: uint32(unsafe.Sizeof(guiThreadInfo{}))}
|
||||
ok, _, _ := u32.NewProc("GetGUIThreadInfo").Call(thread, uintptr(unsafe.Pointer(&info)))
|
||||
return ok != 0 && info.Flags&(0x4|0x10) != 0
|
||||
}
|
||||
|
||||
func TestNativeTrayMouseKeyboardRestartAndControllerExit(t *testing.T) {
|
||||
requireNativeGUI(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
restored := make(chan struct{})
|
||||
tr, err := newTray(cancel, restored)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tr.Close()
|
||||
h := tr.hwnd.Load()
|
||||
thread, _, _ := u32.NewProc("GetWindowThreadProcessId").Call(h, 0)
|
||||
f := newFake()
|
||||
p := f.add(10, 150, PriorityClassNormal)
|
||||
changed := make(chan struct{})
|
||||
var once sync.Once
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
err := runController(ctx, options{mem: 100, interval: time.Minute}, f, func(r scanReport) error {
|
||||
if len(r.Rows) > 0 {
|
||||
once.Do(func() { close(changed) })
|
||||
}
|
||||
return nil
|
||||
})
|
||||
close(restored)
|
||||
finished <- err
|
||||
}()
|
||||
select {
|
||||
case <-changed:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("controller did not start")
|
||||
}
|
||||
waitNative(t, func() bool { _, ok := ownIconRect(h); return ok }, "notification icon registration")
|
||||
// Post only to OUR window. No mouse injection, no interaction with other applications.
|
||||
for _, event := range []uint32{0x400, 0x7b, 0x208, 0x20c} {
|
||||
rect, _ := ownIconRect(h)
|
||||
wp := uintptr(uint16(rect.Left)) | uintptr(uint16(rect.Top))<<16
|
||||
postOwned(t, h, trayCallbackMessage, wp, uintptr(event)|trayIconID<<16)
|
||||
waitNative(t, func() bool { return ownMenuActive(thread) }, "popup for a mouse button")
|
||||
postOwned(t, h, 0x1f, 0, 0)
|
||||
waitNative(t, func() bool { return !tr.menuOpen.Load() }, "menu cancellation")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("cancelling menu unexpectedly exited application")
|
||||
default:
|
||||
}
|
||||
}
|
||||
// Simulate loss of THIS icon, not an actual restart of the user's Explorer.
|
||||
data := notifyIconData{Size: uint32(unsafe.Sizeof(notifyIconData{})), Window: h, ID: trayIconID}
|
||||
removed, _, _ := pShellNotify.Call(2, uintptr(unsafe.Pointer(&data)))
|
||||
if removed == 0 {
|
||||
t.Fatal("could not remove own test icon")
|
||||
}
|
||||
postOwned(t, h, tr.taskbar, 0, 0)
|
||||
waitNative(t, func() bool { _, ok := ownIconRect(h); return ok }, "icon recreation after TaskbarCreated")
|
||||
// The keyboard notification opens the same real menu; select its only item.
|
||||
postOwned(t, h, trayCallbackMessage, 0, 0x401|trayIconID<<16)
|
||||
waitNative(t, func() bool { return ownMenuActive(thread) }, "keyboard menu")
|
||||
postOwned(t, h, 0x100, 0x28, 0) // VK_DOWN
|
||||
postOwned(t, h, 0x100, 0x0d, 0) // VK_RETURN
|
||||
select {
|
||||
case err = <-finished:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("menu selection did not finish restoration")
|
||||
}
|
||||
assertPriority(t, p, PriorityClassNormal)
|
||||
assertClosed(t, f)
|
||||
if err = tr.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := ownIconRect(h); ok {
|
||||
t.Fatal("notification icon was not removed")
|
||||
}
|
||||
}
|
||||
func TestTrayCoordinatesAndNativeLayout(t *testing.T) {
|
||||
x, y := int16(-1200), int16(1400)
|
||||
wp := uintptr(uint16(x)) | uintptr(uint16(y))<<16
|
||||
event, id, point, anchored := trayEvent(true, wp, 0x400|trayIconID<<16)
|
||||
if event != 0x400 || id != trayIconID || point.X != -1200 || point.Y != 1400 || !anchored {
|
||||
t.Fatal("signed multi-monitor anchor lost")
|
||||
}
|
||||
_, _, _, anchored = trayEvent(true, wp, 0x7b|trayIconID<<16)
|
||||
if anchored {
|
||||
t.Fatal("WM_CONTEXTMENU wParam is undefined in the documented v4 contract")
|
||||
}
|
||||
size := unsafe.Sizeof(uintptr(0))
|
||||
want := map[uintptr]uintptr{4: 956, 8: 976}[size]
|
||||
if unsafe.Sizeof(notifyIconData{}) != want {
|
||||
t.Fatal("NOTIFYICONDATAW layout mismatch")
|
||||
}
|
||||
if unsafe.Sizeof(bitmapHeader{}) != 40 {
|
||||
t.Fatal("BITMAPINFOHEADER layout mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func requireNativeGUI(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("AP_NATIVE_GUI") != "1" {
|
||||
t.Skip("opt-in native GUI test; no process scanner is run")
|
||||
}
|
||||
}
|
||||
func TestNativeTrayOneExitItemAndGracefulCommand(t *testing.T) {
|
||||
requireNativeGUI(t)
|
||||
requested := make(chan struct{})
|
||||
restored := make(chan struct{})
|
||||
close(restored)
|
||||
var once sync.Once
|
||||
tr, err := newTray(func() { once.Do(func() { close(requested) }) }, restored)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tr.Close()
|
||||
h := tr.hwnd.Load()
|
||||
count, _, _ := u32.NewProc("GetMenuItemCount").Call(tr.menu)
|
||||
if count != 1 {
|
||||
t.Fatalf("want exactly one menu item, got %d", count)
|
||||
}
|
||||
var label [32]uint16
|
||||
item := menuItemInfo{Size: uint32(unsafe.Sizeof(menuItemInfo{})), Mask: 0x40 | 0x2 | 0x80, Text: &label[0], TextLen: uint32(len(label))}
|
||||
ok, _, e := u32.NewProc("GetMenuItemInfoW").Call(tr.menu, 0, 1, uintptr(unsafe.Pointer(&item)))
|
||||
if ok == 0 {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if item.ID != menuExitID || item.Bitmap == 0 || string(utf16Runes(label[:])) != "Выход" {
|
||||
t.Fatal("missing Exit label, command, or cross bitmap")
|
||||
}
|
||||
ok, _, e = pPostMessage.Call(h, 0x111, menuExitID, 0)
|
||||
if ok == 0 {
|
||||
t.Fatal(e)
|
||||
}
|
||||
select {
|
||||
case <-requested:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Exit command did not request graceful shutdown")
|
||||
}
|
||||
if err = tr.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid, _, _ := u32.NewProc("IsWindow").Call(h)
|
||||
if valid != 0 {
|
||||
t.Fatal("tray window leaked")
|
||||
}
|
||||
}
|
||||
func utf16Runes(in []uint16) []rune {
|
||||
out := []rune{}
|
||||
for _, v := range in {
|
||||
if v == 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, rune(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrayMouseButtonsAndKeyboard(t *testing.T) {
|
||||
for _, event := range []uint32{0x400, 0x401, 0x7b, 0x208, 0x20c} {
|
||||
if !trayActivation(true, event) {
|
||||
t.Fatalf("version 4 activation missing: %#x", event)
|
||||
}
|
||||
}
|
||||
for _, event := range []uint32{0x202, 0x205, 0x208, 0x20c} {
|
||||
if !trayActivation(false, event) {
|
||||
t.Fatalf("legacy activation missing: %#x", event)
|
||||
}
|
||||
}
|
||||
for _, event := range []uint32{0x202, 0x205, 0x200, 0} {
|
||||
if trayActivation(true, event) {
|
||||
t.Fatalf("duplicate or spurious version 4 activation: %#x", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestEmbeddedOfficialSilkAssets(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
data []byte
|
||||
hash string
|
||||
}{{trayPNG, "f52dca6b882152f3a4f2459ea8a02a75a6d925bf18082cae31bb0ecc254fb9eb"}, {exitPNG, "d04ecfc93ff86c44f6fc39e35945e3d8a7648ba8fcd97a2635920df2e88893b3"}} {
|
||||
if fmt.Sprintf("%x", sha256.Sum256(test.data)) != test.hash {
|
||||
t.Fatal("asset differs from pinned official repository")
|
||||
}
|
||||
config, err := png.DecodeConfig(bytes.NewReader(test.data))
|
||||
if err != nil || config.Width != 16 || config.Height != 16 {
|
||||
t.Fatal("invalid Silk image", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
trayCallbackMessage = 0x8001
|
||||
trayQuitMessage = 0x8002
|
||||
trayIconID = 1
|
||||
menuExitID = 1
|
||||
)
|
||||
|
||||
type winPoint struct{ X, Y int32 }
|
||||
type winRect struct{ Left, Top, Right, Bottom int32 }
|
||||
type winMessage struct {
|
||||
Window uintptr
|
||||
Message uint32
|
||||
WParam, LParam uintptr
|
||||
Time uint32
|
||||
Point winPoint
|
||||
Private uint32
|
||||
}
|
||||
type windowClass struct {
|
||||
Size, Style uint32
|
||||
Proc uintptr
|
||||
ClassExtra, WindowExtra int32
|
||||
Instance, Icon, Cursor, Background uintptr
|
||||
MenuName, ClassName *uint16
|
||||
SmallIcon uintptr
|
||||
}
|
||||
type notifyIconData struct {
|
||||
Size uint32
|
||||
Window uintptr
|
||||
ID, Flags, Callback uint32
|
||||
Icon uintptr
|
||||
Tip [128]uint16
|
||||
State, StateMask uint32
|
||||
Info [256]uint16
|
||||
Version uint32
|
||||
InfoTitle [64]uint16
|
||||
InfoFlags uint32
|
||||
GUID [16]byte
|
||||
BalloonIcon uintptr
|
||||
}
|
||||
type notifyIconIdentifier struct {
|
||||
Size uint32
|
||||
Window uintptr
|
||||
ID uint32
|
||||
GUID [16]byte
|
||||
}
|
||||
type menuItemInfo struct {
|
||||
Size, Mask, Type, State, ID uint32
|
||||
Submenu, Checked, Unchecked, Data uintptr
|
||||
Text *uint16
|
||||
TextLen uint32
|
||||
Bitmap uintptr
|
||||
}
|
||||
type bitmapHeader struct {
|
||||
Size uint32
|
||||
Width, Height int32
|
||||
Planes, BitCount uint16
|
||||
Compression, ImageSize uint32
|
||||
XPels, YPels int32
|
||||
ColorsUsed, ColorsImportant uint32
|
||||
}
|
||||
type iconInfo struct {
|
||||
Icon int32
|
||||
HotspotX, HotspotY uint32
|
||||
Mask, Color uintptr
|
||||
}
|
||||
|
||||
func systemDLL(name string) *syscall.LazyDLL {
|
||||
var dir [32768]uint16
|
||||
r, _, err := k32.NewProc("GetSystemDirectoryW").Call(uintptr(unsafe.Pointer(&dir[0])), uintptr(len(dir)))
|
||||
if r == 0 || r >= uintptr(len(dir)) {
|
||||
panic(fmt.Sprintf("GetSystemDirectoryW: %v", err))
|
||||
}
|
||||
return syscall.NewLazyDLL(filepath.Join(syscall.UTF16ToString(dir[:r]), name))
|
||||
}
|
||||
|
||||
var (
|
||||
u32 = systemDLL("user32.dll")
|
||||
shell32 = systemDLL("shell32.dll")
|
||||
gdi32 = systemDLL("gdi32.dll")
|
||||
pRegisterClass = u32.NewProc("RegisterClassExW")
|
||||
pUnregisterClass = u32.NewProc("UnregisterClassW")
|
||||
pCreateWindow = u32.NewProc("CreateWindowExW")
|
||||
pDestroyWindow = u32.NewProc("DestroyWindow")
|
||||
pDefWindowProc = u32.NewProc("DefWindowProcW")
|
||||
pGetMessage = u32.NewProc("GetMessageW")
|
||||
pTranslateMessage = u32.NewProc("TranslateMessage")
|
||||
pDispatchMessage = u32.NewProc("DispatchMessageW")
|
||||
pPostQuit = u32.NewProc("PostQuitMessage")
|
||||
pPostMessage = u32.NewProc("PostMessageW")
|
||||
pCreatePopup = u32.NewProc("CreatePopupMenu")
|
||||
pDestroyMenu = u32.NewProc("DestroyMenu")
|
||||
pInsertMenuItem = u32.NewProc("InsertMenuItemW")
|
||||
pTrackPopup = u32.NewProc("TrackPopupMenu")
|
||||
pForeground = u32.NewProc("SetForegroundWindow")
|
||||
pEndMenu = u32.NewProc("EndMenu")
|
||||
pCursorPos = u32.NewProc("GetCursorPos")
|
||||
pRegisterMessage = u32.NewProc("RegisterWindowMessageW")
|
||||
pMessageFilter = u32.NewProc("ChangeWindowMessageFilterEx")
|
||||
pCreateIcon = u32.NewProc("CreateIconIndirect")
|
||||
pDestroyIcon = u32.NewProc("DestroyIcon")
|
||||
pCreateDIB = gdi32.NewProc("CreateDIBSection")
|
||||
pCreateBitmap = gdi32.NewProc("CreateBitmap")
|
||||
pDeleteObject = gdi32.NewProc("DeleteObject")
|
||||
pShellNotify = shell32.NewProc("Shell_NotifyIconW")
|
||||
pNotifyRect = shell32.NewProc("Shell_NotifyIconGetRect")
|
||||
)
|
||||
|
||||
type tray struct {
|
||||
hwnd atomic.Uintptr
|
||||
menu uintptr
|
||||
data notifyIconData
|
||||
taskbar uint32
|
||||
version4 bool
|
||||
menuOpen atomic.Bool
|
||||
done chan struct{}
|
||||
err error
|
||||
failure error // message-loop thread only
|
||||
onExit func()
|
||||
restored <-chan struct{}
|
||||
exitOnce sync.Once
|
||||
added bool
|
||||
}
|
||||
|
||||
func newTray(onExit func(), restored <-chan struct{}) (*tray, error) {
|
||||
t := &tray{done: make(chan struct{}), onExit: onExit, restored: restored}
|
||||
ready := make(chan error, 1)
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
t.err = t.loop(ready)
|
||||
t.requestExit()
|
||||
close(t.done)
|
||||
}()
|
||||
if err := <-ready; err != nil {
|
||||
<-t.done
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
func (t *tray) Close() error {
|
||||
select {
|
||||
case <-t.done:
|
||||
return t.err
|
||||
default:
|
||||
}
|
||||
if h := t.hwnd.Load(); h != 0 {
|
||||
r, _, e := pPostMessage.Call(h, trayQuitMessage, 0, 0)
|
||||
if r == 0 {
|
||||
return fmt.Errorf("PostMessage(close): %v", e)
|
||||
}
|
||||
}
|
||||
<-t.done
|
||||
return t.err
|
||||
}
|
||||
|
||||
var trayWindows sync.Map
|
||||
var trayWindowCallback = syscall.NewCallback(trayWindowProc)
|
||||
|
||||
func (t *tray) requestExit() {
|
||||
t.exitOnce.Do(func() {
|
||||
if t.onExit != nil {
|
||||
t.onExit()
|
||||
}
|
||||
})
|
||||
}
|
||||
func (t *tray) fail(err error) {
|
||||
if t.failure == nil {
|
||||
t.failure = err
|
||||
}
|
||||
t.requestExit()
|
||||
}
|
||||
func pngBitmap(data []byte) (uintptr, error) {
|
||||
image, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if image.Bounds().Dx() != 16 || image.Bounds().Dy() != 16 {
|
||||
return 0, fmt.Errorf("expected a 16x16 Silk icon")
|
||||
}
|
||||
header := bitmapHeader{Size: uint32(unsafe.Sizeof(bitmapHeader{})), Width: 16, Height: -16, Planes: 1, BitCount: 32}
|
||||
var bits unsafe.Pointer
|
||||
bitmap, _, e := pCreateDIB.Call(0, uintptr(unsafe.Pointer(&header)), 0, uintptr(unsafe.Pointer(&bits)), 0, 0)
|
||||
if bitmap == 0 {
|
||||
return 0, fmt.Errorf("CreateDIBSection: %v", e)
|
||||
}
|
||||
if bits == nil {
|
||||
pDeleteObject.Call(bitmap)
|
||||
return 0, fmt.Errorf("CreateDIBSection returned no pixels")
|
||||
}
|
||||
pixels := unsafe.Slice((*byte)(bits), 16*16*4)
|
||||
for y := 0; y < 16; y++ {
|
||||
for x := 0; x < 16; x++ {
|
||||
r, g, b, a := image.At(x, y).RGBA()
|
||||
offset := (y*16 + x) * 4
|
||||
// RGBA() is premultiplied; native menu bitmaps require premultiplied BGRA.
|
||||
pixels[offset] = byte(b >> 8)
|
||||
pixels[offset+1] = byte(g >> 8)
|
||||
pixels[offset+2] = byte(r >> 8)
|
||||
pixels[offset+3] = byte(a >> 8)
|
||||
}
|
||||
}
|
||||
return bitmap, nil
|
||||
}
|
||||
func pngIcon(data []byte) (uintptr, error) {
|
||||
color, err := pngBitmap(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer pDeleteObject.Call(color)
|
||||
var maskBits [32]byte
|
||||
mask, _, e := pCreateBitmap.Call(16, 16, 1, 1, uintptr(unsafe.Pointer(&maskBits[0])))
|
||||
if mask == 0 {
|
||||
return 0, fmt.Errorf("CreateBitmap(mask): %v", e)
|
||||
}
|
||||
defer pDeleteObject.Call(mask)
|
||||
info := iconInfo{Icon: 1, Mask: mask, Color: color}
|
||||
icon, _, e := pCreateIcon.Call(uintptr(unsafe.Pointer(&info)))
|
||||
if icon == 0 {
|
||||
return 0, fmt.Errorf("CreateIconIndirect: %v", e)
|
||||
}
|
||||
return icon, nil
|
||||
}
|
||||
func (t *tray) addIcon() error {
|
||||
t.data.Flags = 0x87 // NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP
|
||||
ok, _, _ := pShellNotify.Call(0, uintptr(unsafe.Pointer(&t.data))) // NIM_ADD
|
||||
if ok == 0 {
|
||||
ok, _, _ = pShellNotify.Call(1, uintptr(unsafe.Pointer(&t.data)))
|
||||
} // existing icon: NIM_MODIFY
|
||||
if ok == 0 {
|
||||
return fmt.Errorf("Shell_NotifyIcon: notification area unavailable")
|
||||
}
|
||||
t.added = true
|
||||
t.data.Version = 4
|
||||
ok, _, _ = pShellNotify.Call(4, uintptr(unsafe.Pointer(&t.data))) // NIM_SETVERSION, after EVERY add
|
||||
t.version4 = ok != 0
|
||||
return nil
|
||||
}
|
||||
func (t *tray) removeIcon() {
|
||||
if t.added {
|
||||
pShellNotify.Call(2, uintptr(unsafe.Pointer(&t.data)))
|
||||
t.added = false
|
||||
}
|
||||
}
|
||||
func (t *tray) loop(ready chan<- error) (result error) {
|
||||
sent := false
|
||||
defer func() {
|
||||
if !sent {
|
||||
ready <- result
|
||||
}
|
||||
}()
|
||||
icon, err := pngIcon(trayPNG)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pDestroyIcon.Call(icon)
|
||||
cross, err := pngBitmap(exitPNG)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pDeleteObject.Call(cross)
|
||||
menu, _, e := pCreatePopup.Call()
|
||||
if menu == 0 {
|
||||
return fmt.Errorf("CreatePopupMenu: %v", e)
|
||||
}
|
||||
t.menu = menu
|
||||
defer pDestroyMenu.Call(menu)
|
||||
label, _ := syscall.UTF16PtrFromString("Выход")
|
||||
item := menuItemInfo{Size: uint32(unsafe.Sizeof(menuItemInfo{})), Mask: 0x2 | 0x40 | 0x80, ID: menuExitID, Text: label, Bitmap: cross}
|
||||
ok, _, e := pInsertMenuItem.Call(menu, 0, 1, uintptr(unsafe.Pointer(&item)))
|
||||
if ok == 0 {
|
||||
return fmt.Errorf("InsertMenuItemW: %v", e)
|
||||
}
|
||||
instance, _, e := k32.NewProc("GetModuleHandleW").Call(0)
|
||||
if instance == 0 {
|
||||
return fmt.Errorf("GetModuleHandleW: %v", e)
|
||||
}
|
||||
className, _ := syscall.UTF16PtrFromString(fmt.Sprintf("autoPriority.Tray.%d", os.Getpid()))
|
||||
wc := windowClass{Size: uint32(unsafe.Sizeof(windowClass{})), Proc: trayWindowCallback, Instance: instance, ClassName: className, Icon: icon, SmallIcon: icon}
|
||||
atom, _, e := pRegisterClass.Call(uintptr(unsafe.Pointer(&wc)))
|
||||
if atom == 0 {
|
||||
return fmt.Errorf("RegisterClassExW: %v", e)
|
||||
}
|
||||
defer pUnregisterClass.Call(uintptr(unsafe.Pointer(className)), instance)
|
||||
// Hidden TOP-LEVEL window: a message-only window would miss TaskbarCreated.
|
||||
h, _, e := pCreateWindow.Call(0, uintptr(unsafe.Pointer(className)), 0, 0, 0, 0, 0, 0, 0, 0, instance, 0)
|
||||
if h == 0 {
|
||||
return fmt.Errorf("CreateWindowExW: %v", e)
|
||||
}
|
||||
t.hwnd.Store(h)
|
||||
trayWindows.Store(h, t)
|
||||
defer func() {
|
||||
if t.hwnd.Load() != 0 {
|
||||
pDestroyWindow.Call(h)
|
||||
}
|
||||
trayWindows.Delete(h)
|
||||
}()
|
||||
taskbarName, _ := syscall.UTF16PtrFromString("TaskbarCreated")
|
||||
taskbar, _, e := pRegisterMessage.Call(uintptr(unsafe.Pointer(taskbarName)))
|
||||
if taskbar == 0 {
|
||||
return fmt.Errorf("RegisterWindowMessageW: %v", e)
|
||||
}
|
||||
t.taskbar = uint32(taskbar)
|
||||
// Only the Explorer restart message crosses an elevated window's UIPI boundary.
|
||||
ok, _, e = pMessageFilter.Call(h, taskbar, 1, 0)
|
||||
if ok == 0 {
|
||||
return fmt.Errorf("ChangeWindowMessageFilterEx: %v", e)
|
||||
}
|
||||
t.data = notifyIconData{Size: uint32(unsafe.Sizeof(notifyIconData{})), Window: h, ID: trayIconID, Callback: trayCallbackMessage, Icon: icon}
|
||||
tip, _ := syscall.UTF16FromString("autoPriority")
|
||||
copy(t.data.Tip[:], tip)
|
||||
if err = t.addIcon(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer t.removeIcon()
|
||||
ready <- nil
|
||||
sent = true
|
||||
for {
|
||||
var msg winMessage
|
||||
got, _, e := pGetMessage.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0)
|
||||
if int32(got) == -1 {
|
||||
return fmt.Errorf("GetMessageW: %v", e)
|
||||
}
|
||||
if got == 0 {
|
||||
return t.failure
|
||||
}
|
||||
pTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
pDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
}
|
||||
}
|
||||
func trayEvent(version4 bool, wp, lp uintptr) (event, id uint32, point winPoint, anchored bool) {
|
||||
event = uint32(lp)
|
||||
id = uint32(wp)
|
||||
if version4 {
|
||||
event = uint32(lp) & 0xffff
|
||||
id = uint32(lp>>16) & 0xffff
|
||||
if event == 0x400 || event == 0x401 || (event >= 0x200 && event <= 0x20e) {
|
||||
point = winPoint{int32(int16(wp & 0xffff)), int32(int16((wp >> 16) & 0xffff))}
|
||||
anchored = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
func (t *tray) popup(point winPoint, anchored bool) {
|
||||
if t.menuOpen.Swap(true) {
|
||||
return
|
||||
}
|
||||
defer t.menuOpen.Store(false)
|
||||
if !anchored {
|
||||
ok, _, _ := pCursorPos.Call(uintptr(unsafe.Pointer(&point)))
|
||||
if ok == 0 {
|
||||
point = winPoint{}
|
||||
}
|
||||
}
|
||||
h := t.hwnd.Load()
|
||||
if h == 0 {
|
||||
return
|
||||
}
|
||||
pForeground.Call(h)
|
||||
k32.NewProc("SetLastError").Call(0)
|
||||
command, _, e := pTrackPopup.Call(t.menu, 0x100|0x80|0x2, uintptr(point.X), uintptr(point.Y), 0, h, 0)
|
||||
pPostMessage.Call(h, 0, 0, 0) // documented focus workaround after TrackPopupMenu
|
||||
if command == menuExitID {
|
||||
t.requestExit()
|
||||
} else if command == 0 && e != syscall.Errno(0) {
|
||||
t.fail(fmt.Errorf("TrackPopupMenu: %v", e))
|
||||
}
|
||||
pShellNotify.Call(3, uintptr(unsafe.Pointer(&t.data))) // NIM_SETFOCUS
|
||||
}
|
||||
func trayWindowProc(h uintptr, message uint32, wp, lp uintptr) uintptr {
|
||||
value, ok := trayWindows.Load(h)
|
||||
if !ok {
|
||||
r, _, _ := pDefWindowProc.Call(h, uintptr(message), wp, lp)
|
||||
return r
|
||||
}
|
||||
t := value.(*tray)
|
||||
if message == t.taskbar {
|
||||
if err := t.addIcon(); err != nil {
|
||||
t.fail(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
switch message {
|
||||
case trayCallbackMessage:
|
||||
event, id, point, anchored := trayEvent(t.version4, wp, lp)
|
||||
if id == trayIconID && trayActivation(t.version4, event) {
|
||||
t.popup(point, anchored)
|
||||
}
|
||||
return 0
|
||||
case 0x111:
|
||||
if uint32(wp)&0xffff == menuExitID {
|
||||
t.requestExit()
|
||||
}
|
||||
return 0 // WM_COMMAND
|
||||
case 0x10:
|
||||
t.requestExit()
|
||||
return 0 // WM_CLOSE requests cleanup, not forceful exit
|
||||
case 0x11:
|
||||
return 1 // WM_QUERYENDSESSION
|
||||
case 0x16: // WM_ENDSESSION: give the controller a bounded chance to restore
|
||||
if wp != 0 {
|
||||
t.requestExit()
|
||||
select {
|
||||
case <-t.restored:
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case 0x1f:
|
||||
pEndMenu.Call()
|
||||
return 0 // WM_CANCELMODE
|
||||
case trayQuitMessage:
|
||||
pEndMenu.Call()
|
||||
pDestroyWindow.Call(h)
|
||||
return 0
|
||||
case 0x2:
|
||||
t.removeIcon()
|
||||
t.hwnd.Store(0)
|
||||
t.requestExit()
|
||||
pPostQuit.Call(0)
|
||||
return 0 // WM_DESTROY
|
||||
case 0x82:
|
||||
trayWindows.Delete(h) // WM_NCDESTROY
|
||||
}
|
||||
r, _, _ := pDefWindowProc.Call(h, uintptr(message), wp, lp)
|
||||
return r
|
||||
}
|
||||
func showError(err error) {
|
||||
console, _, _ := k32.NewProc("GetConsoleWindow").Call()
|
||||
if console != 0 {
|
||||
return
|
||||
}
|
||||
text, _ := syscall.UTF16PtrFromString("autoPriority: " + err.Error())
|
||||
title, _ := syscall.UTF16PtrFromString("autoPriority")
|
||||
u32.NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)), 0x10)
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
CreateToolhelp32SnapshotProcess = 0x00000002
|
||||
ProcessSetInformation = 0x00000200
|
||||
ProcessQueryLimitedInformation = 0x00001000
|
||||
|
||||
ErrorNoMoreFiles = 18
|
||||
ErrorAlreadyExists = 183
|
||||
)
|
||||
|
||||
type processEntry32 struct {
|
||||
Size uint32
|
||||
CntUsage uint32
|
||||
PID uint32
|
||||
DefaultHeapID uintptr
|
||||
ModuleID uint32
|
||||
CntThreads uint32
|
||||
ParentPID uint32
|
||||
PrioClass int32
|
||||
Flags uint32
|
||||
ExeFile [260]uint16
|
||||
}
|
||||
|
||||
type processMemoryCounters struct {
|
||||
CBM uint32
|
||||
PageFaultCount uint32
|
||||
PeakWorkingSetSize uintptr
|
||||
WorkingSetSize uintptr
|
||||
QuotaPeakPagedPoolUsage uintptr
|
||||
QuotaPagedPoolUsage uintptr
|
||||
QuotaPeakNonPagedPoolUsage uintptr
|
||||
QuotaNonPagedPoolUsage uintptr
|
||||
PagefileUsage uintptr
|
||||
PeakPagefileUsage uintptr
|
||||
PrivateUsage uintptr
|
||||
}
|
||||
|
||||
type processKey struct {
|
||||
PID uint32
|
||||
Created uint64
|
||||
}
|
||||
|
||||
type procInfo struct {
|
||||
PID uint32
|
||||
Name string
|
||||
RSS uint64
|
||||
Key processKey
|
||||
Prio uint32
|
||||
Handle syscall.Handle
|
||||
Waitable bool
|
||||
ParentPID uint32
|
||||
}
|
||||
|
||||
var (
|
||||
k32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
|
||||
procCreateSnap = k32.NewProc("CreateToolhelp32Snapshot")
|
||||
procProcess32First = k32.NewProc("Process32FirstW")
|
||||
procProcess32Next = k32.NewProc("Process32NextW")
|
||||
procGetMemInfo = k32.NewProc("K32GetProcessMemoryInfo")
|
||||
procSetPriority = k32.NewProc("SetPriorityClass")
|
||||
procGetPriority = k32.NewProc("GetPriorityClass")
|
||||
procOpenProcess = k32.NewProc("OpenProcess")
|
||||
procCloseHandle = k32.NewProc("CloseHandle")
|
||||
procGetProcessTimes = k32.NewProc("GetProcessTimes")
|
||||
procQueryImageName = k32.NewProc("QueryFullProcessImageNameW")
|
||||
procCreateMutex = k32.NewProc("CreateMutexW")
|
||||
)
|
||||
|
||||
func closeH(h syscall.Handle) {
|
||||
_, _, _ = procCloseHandle.Call(uintptr(h))
|
||||
}
|
||||
|
||||
func openProc(pid uint32, acc uint32) (syscall.Handle, error) {
|
||||
r, _, e := procOpenProcess.Call(uintptr(acc), 0, uintptr(pid))
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("OpenProcess(%d) failed: %w", pid, e)
|
||||
}
|
||||
return syscall.Handle(r), nil
|
||||
}
|
||||
|
||||
func setPrio(h syscall.Handle, pid uint32, cls uint32) error {
|
||||
r, _, e := procSetPriority.Call(uintptr(h), uintptr(cls))
|
||||
if r == 0 {
|
||||
return fmt.Errorf("SetPriorityClass(%d) failed: %w", pid, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processCreated(h syscall.Handle) (uint64, error) {
|
||||
var created, exited, kernel, user syscall.Filetime
|
||||
r, _, e := procGetProcessTimes.Call(
|
||||
uintptr(h),
|
||||
uintptr(unsafe.Pointer(&created)),
|
||||
uintptr(unsafe.Pointer(&exited)),
|
||||
uintptr(unsafe.Pointer(&kernel)),
|
||||
uintptr(unsafe.Pointer(&user)),
|
||||
)
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("GetProcessTimes failed: %w", e)
|
||||
}
|
||||
return uint64(created.HighDateTime)<<32 | uint64(created.LowDateTime), nil
|
||||
}
|
||||
|
||||
func processName(h syscall.Handle, buf []uint16) (string, error) {
|
||||
size := uint32(len(buf))
|
||||
r, _, e := procQueryImageName.Call(uintptr(h), 0, uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size)))
|
||||
if r == 0 {
|
||||
return "", fmt.Errorf("QueryFullProcessImageName failed: %w", e)
|
||||
}
|
||||
return filepath.Base(syscall.UTF16ToString(buf[:size])), nil
|
||||
}
|
||||
|
||||
func processAlive(h syscall.Handle) (bool, error) {
|
||||
r, _, err := procWaitForSingleObject.Call(uintptr(h), 0)
|
||||
return decodeWaitResult(r, err)
|
||||
}
|
||||
func decodeWaitResult(r uintptr, err error) (bool, error) {
|
||||
switch r {
|
||||
case 0:
|
||||
return false, nil
|
||||
case 0x102:
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("WaitForSingleObject returned %#x: %v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func allProcs() ([]procInfo, error) {
|
||||
snap, _, e := procCreateSnap.Call(CreateToolhelp32SnapshotProcess, 0)
|
||||
if snap == ^uintptr(0) {
|
||||
return nil, fmt.Errorf("CreateToolhelp32Snapshot failed: %w", e)
|
||||
}
|
||||
defer closeH(syscall.Handle(snap))
|
||||
|
||||
pe := processEntry32{Size: uint32(unsafe.Sizeof(processEntry32{}))}
|
||||
r, _, e := procProcess32First.Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||
if r == 0 {
|
||||
if e == syscall.Errno(ErrorNoMoreFiles) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Process32First failed: %w", e)
|
||||
}
|
||||
|
||||
var out []procInfo
|
||||
for {
|
||||
name := syscall.UTF16ToString(pe.ExeFile[:])
|
||||
out = append(out, procInfo{PID: pe.PID, ParentPID: pe.ParentPID, Name: name})
|
||||
pe.Size = uint32(unsafe.Sizeof(processEntry32{}))
|
||||
r, _, e = procProcess32Next.Call(snap, uintptr(unsafe.Pointer(&pe)))
|
||||
if r == 0 {
|
||||
if e == syscall.Errno(ErrorNoMoreFiles) {
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("Process32Next failed: %w", e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func singleInstance() (syscall.Handle, bool, error) {
|
||||
name, err := syscall.UTF16PtrFromString(`Local\autoPriority`)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
r, _, e := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(name)))
|
||||
if r == 0 {
|
||||
return 0, false, fmt.Errorf("CreateMutex failed: %w", e)
|
||||
}
|
||||
return syscall.Handle(r), e == syscall.Errno(ErrorAlreadyExists), nil
|
||||
}
|
||||
|
||||
const processSynchronize = 0x00100000
|
||||
|
||||
var procWaitForSingleObject = k32.NewProc("WaitForSingleObject")
|
||||
|
||||
type windowsBackend struct {
|
||||
list func() ([]procInfo, error)
|
||||
self uint32
|
||||
nameBuffer [32768]uint16
|
||||
unreadable map[uint32]procInfo
|
||||
}
|
||||
|
||||
func newWindowsBackend() *windowsBackend {
|
||||
return &windowsBackend{list: allProcs, self: uint32(os.Getpid()), unreadable: make(map[uint32]procInfo)}
|
||||
}
|
||||
func (w *windowsBackend) inspect(p procInfo) (procInfo, error) {
|
||||
h, err := openProc(p.PID, ProcessQueryLimitedInformation|processSynchronize)
|
||||
p.Waitable = err == nil
|
||||
if err != nil {
|
||||
h, err = openProc(p.PID, ProcessQueryLimitedInformation)
|
||||
}
|
||||
if err != nil {
|
||||
return procInfo{}, err
|
||||
}
|
||||
good := false
|
||||
defer func() {
|
||||
if !good {
|
||||
closeH(h)
|
||||
}
|
||||
}()
|
||||
created, err := processCreated(h)
|
||||
if err != nil {
|
||||
return procInfo{}, err
|
||||
}
|
||||
if name, e := processName(h, w.nameBuffer[:]); e == nil {
|
||||
p.Name = name
|
||||
}
|
||||
p.Handle = h
|
||||
p.Key = processKey{PID: p.PID, Created: created}
|
||||
var m processMemoryCounters
|
||||
m.CBM = uint32(unsafe.Sizeof(m))
|
||||
r, _, e := procGetMemInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&m)), uintptr(unsafe.Sizeof(m)))
|
||||
if r == 0 {
|
||||
return procInfo{}, fmt.Errorf("K32GetProcessMemoryInfo: %w", e)
|
||||
}
|
||||
p.RSS = uint64(m.WorkingSetSize)
|
||||
p.Prio, err = w.Priority(&p)
|
||||
if err != nil {
|
||||
return procInfo{}, err
|
||||
}
|
||||
good = true
|
||||
return p, nil
|
||||
}
|
||||
func (w *windowsBackend) Snapshot() (processSnapshot, error) {
|
||||
entries, err := w.list()
|
||||
if err != nil {
|
||||
return processSnapshot{}, err
|
||||
}
|
||||
snap := processSnapshot{PIDs: make(map[uint32]bool, len(entries)), Processes: make([]procInfo, 0, len(entries))}
|
||||
for _, entry := range entries {
|
||||
snap.PIDs[entry.PID] = true
|
||||
if entry.PID == 0 || entry.PID == w.self {
|
||||
continue
|
||||
}
|
||||
if blocked, ok := w.unreadable[entry.PID]; ok && blocked.Name == entry.Name && blocked.ParentPID == entry.ParentPID {
|
||||
continue
|
||||
}
|
||||
info, err := w.inspect(entry)
|
||||
if err != nil {
|
||||
snap.Errors = append(snap.Errors, processError{Name: entry.Name, PID: entry.PID, Description: fmt.Sprintf("чтение: %v; внесён в блоклист", err)})
|
||||
w.unreadable[entry.PID] = entry
|
||||
continue
|
||||
}
|
||||
delete(w.unreadable, entry.PID)
|
||||
snap.Processes = append(snap.Processes, info)
|
||||
}
|
||||
for pid := range w.unreadable {
|
||||
if !snap.PIDs[pid] {
|
||||
delete(w.unreadable, pid)
|
||||
}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
func (w *windowsBackend) Alive(p *procInfo) (bool, error) { return processAlive(p.Handle) }
|
||||
func (w *windowsBackend) Priority(p *procInfo) (uint32, error) {
|
||||
r, _, err := procGetPriority.Call(uintptr(p.Handle))
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("GetPriorityClass: %w", err)
|
||||
}
|
||||
return uint32(r), nil
|
||||
}
|
||||
func (w *windowsBackend) SetPriority(p *procInfo, target uint32) error {
|
||||
// Request write access only for an actual change, never while enumerating.
|
||||
h, err := openProc(p.PID, ProcessQueryLimitedInformation|ProcessSetInformation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeH(h)
|
||||
created, err := processCreated(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created != p.Key.Created {
|
||||
return fmt.Errorf("process instance changed; refusing to write")
|
||||
}
|
||||
return setPrio(h, p.PID, target)
|
||||
}
|
||||
func (w *windowsBackend) Close(p *procInfo) {
|
||||
if p.Handle != 0 {
|
||||
closeH(p.Handle)
|
||||
p.Handle = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNativeProcessHelper(t *testing.T) {
|
||||
if os.Getenv("AP_OWNED_HELPER") != "1" {
|
||||
t.Skip("helper child only")
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "READY")
|
||||
io.Copy(io.Discard, os.Stdin)
|
||||
os.Exit(0)
|
||||
}
|
||||
func startOwnedHelper(t *testing.T) (uint32, func()) {
|
||||
t.Helper()
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
cmd := exec.CommandContext(ctx, exe, "-test.run=^TestNativeProcessHelper$")
|
||||
cmd.Env = append(os.Environ(), "AP_OWNED_HELPER=1")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000}
|
||||
input, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
input.Close()
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = cmd.Start(); err != nil {
|
||||
input.Close()
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
stop := func() { once.Do(func() { input.Close(); cmd.Wait(); cancel() }) }
|
||||
t.Cleanup(stop)
|
||||
line, err := bufio.NewReader(output).ReadString('\n')
|
||||
if err != nil || line != "READY\n" {
|
||||
t.Fatalf("owned helper did not start: %v", err)
|
||||
}
|
||||
return uint32(cmd.Process.Pid), stop
|
||||
}
|
||||
func requireNativeProcesses(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("AP_NATIVE_PROCESSES") != "1" {
|
||||
t.Skip("opt-in, test-owned children only; never enumerates other processes")
|
||||
}
|
||||
}
|
||||
func TestNativeOwnedProcessRightsIdentityAndLifetime(t *testing.T) {
|
||||
requireNativeProcesses(t)
|
||||
pid, stop := startOwnedHelper(t)
|
||||
backend := newWindowsBackend()
|
||||
// The system-wide enumerator is intentionally NEVER called in native tests.
|
||||
backend.list = func() ([]procInfo, error) { return []procInfo{{PID: pid, Name: "OwnedHelper.exe"}}, nil }
|
||||
snap, err := backend.Snapshot()
|
||||
if err != nil || len(snap.Processes) != 1 {
|
||||
t.Fatalf("owned snapshot failed: %v", err)
|
||||
}
|
||||
p := snap.Processes[0]
|
||||
defer backend.Close(&p)
|
||||
if p.Key.Created == 0 || p.RSS == 0 || p.Name == "" || !p.Waitable {
|
||||
t.Fatal("incomplete owned-process metadata")
|
||||
}
|
||||
alive, err := backend.Alive(&p)
|
||||
if err != nil || !alive {
|
||||
t.Fatal("live helper not recognized", err)
|
||||
}
|
||||
if err = setPrio(p.Handle, p.PID, p.Prio); err == nil {
|
||||
t.Fatal("snapshot handle unexpectedly grants SET_INFORMATION")
|
||||
}
|
||||
original := p.Prio
|
||||
bad := p
|
||||
bad.Key.Created++
|
||||
if err = backend.SetPriority(&bad, PriorityClassHigh); err == nil {
|
||||
t.Fatal("identity mismatch did not prevent write")
|
||||
}
|
||||
now, err := backend.Priority(&p)
|
||||
if err != nil || now != original {
|
||||
t.Fatal("identity check changed priority")
|
||||
}
|
||||
if err = backend.SetPriority(&p, PriorityClassBelowNormal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = backend.Priority(&p)
|
||||
if err != nil || now != PriorityClassBelowNormal {
|
||||
t.Fatal("owned priority write not observed", err)
|
||||
}
|
||||
// Do not request REALTIME in native tests. The child is about to terminate.
|
||||
stop()
|
||||
alive, err = backend.Alive(&p)
|
||||
if err != nil || alive {
|
||||
t.Fatal("signaled process handle not recognized as exited", err)
|
||||
}
|
||||
}
|
||||
func TestNativeControllerRestoresOwnedChild(t *testing.T) {
|
||||
requireNativeProcesses(t)
|
||||
pid, _ := startOwnedHelper(t)
|
||||
backend := newWindowsBackend()
|
||||
backend.list = func() ([]procInfo, error) { return []procInfo{{PID: pid, Name: "OwnedHelper.exe"}}, nil }
|
||||
initial, err := backend.inspect(procInfo{PID: pid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backend.Close(&initial)
|
||||
// Establish a known, non-realtime baseline on this test-owned child.
|
||||
if err = backend.SetPriority(&initial, PriorityClassBelowNormal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newController(backend, 1, 0, false, time.Minute)
|
||||
r := c.Step()
|
||||
if len(r.Errors) != 0 {
|
||||
c.Shutdown()
|
||||
t.Fatal(r.Errors)
|
||||
}
|
||||
actual, err := backend.Priority(&initial)
|
||||
if err != nil || actual != PriorityClassHigh {
|
||||
c.Shutdown()
|
||||
t.Fatal("promotion failed", err)
|
||||
}
|
||||
r = c.Shutdown()
|
||||
if r.Unrestored != 0 || len(r.Errors) != 0 {
|
||||
t.Fatal(r)
|
||||
}
|
||||
actual, err = backend.Priority(&initial)
|
||||
if err != nil || actual != PriorityClassBelowNormal {
|
||||
t.Fatal("baseline not restored", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func TestMemoryCounterLayout(t *testing.T) {
|
||||
var m processMemoryCounters
|
||||
if unsafe.Offsetof(m.PagefileUsage) >= unsafe.Offsetof(m.PeakPagefileUsage) {
|
||||
t.Fatal("PagefileUsage must precede PeakPagefileUsage in the Windows ABI")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user