From 4880aa46dc1836f42f89afed2f1fea3024d74533 Mon Sep 17 00:00:00 2001 From: lzrdblzzrd Date: Sun, 4 Oct 2026 19:00:59 +0300 Subject: [PATCH] feat: add tray control, priority restoration and session logging --- README.md | 179 ++++----- app_test.go | 84 +++++ assets/SILK-LICENSE.md | 37 ++ assets/SILK-SOURCE.json | 20 + assets/chart_bar_edit.png | Bin 0 -> 754 bytes assets/cross.png | Bin 0 -> 655 bytes controller.go | 420 +++++++++++++++++++++ controller_regression_test.go | 288 ++++++++++++++ controller_test.go | 204 ++++++++++ edge_test.go | 52 +++ logging.go | 153 ++++++++ logging_test.go | 57 +++ main.go | 681 +++++----------------------------- policy.go | 97 +++++ requested_behavior_test.go | 102 +++++ rotate.go | 108 ++++++ rotate_test.go | 73 ++++ tray_assets.go | 26 ++ tray_integration_test.go | 145 ++++++++ tray_native_test.go | 70 ++++ tray_test.go | 43 +++ tray_windows.go | 453 ++++++++++++++++++++++ windows.go | 295 +++++++++++++++ windows_native_test.go | 146 ++++++++ windows_test.go | 15 + 25 files changed, 3072 insertions(+), 676 deletions(-) create mode 100644 app_test.go create mode 100644 assets/SILK-LICENSE.md create mode 100644 assets/SILK-SOURCE.json create mode 100644 assets/chart_bar_edit.png create mode 100644 assets/cross.png create mode 100644 controller.go create mode 100644 controller_regression_test.go create mode 100644 controller_test.go create mode 100644 edge_test.go create mode 100644 logging.go create mode 100644 logging_test.go create mode 100644 policy.go create mode 100644 requested_behavior_test.go create mode 100644 rotate.go create mode 100644 rotate_test.go create mode 100644 tray_assets.go create mode 100644 tray_integration_test.go create mode 100644 tray_native_test.go create mode 100644 tray_test.go create mode 100644 tray_windows.go create mode 100644 windows.go create mode 100644 windows_native_test.go create mode 100644 windows_test.go diff --git a/README.md b/README.md index 3024d0e..debd753 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..8159059 --- /dev/null +++ b/app_test.go @@ -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) + } + } +} diff --git a/assets/SILK-LICENSE.md b/assets/SILK-LICENSE.md new file mode 100644 index 0000000..0734049 --- /dev/null +++ b/assets/SILK-LICENSE.md @@ -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. diff --git a/assets/SILK-SOURCE.json b/assets/SILK-SOURCE.json new file mode 100644 index 0000000..649cb4e --- /dev/null +++ b/assets/SILK-SOURCE.json @@ -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 + } + ] +} diff --git a/assets/chart_bar_edit.png b/assets/chart_bar_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..df64d97e615935bd6fc9d39eea5442b2c324b0e4 GIT binary patch literal 754 zcmV!l0{32(CI}(lp$fzNf0TbdAIVWCque*=#U*c zs1=bX}|=>LcQ-(oKM)02%@YgMyfV)Z*0?hzBX{Wscn$j;|pZ= z;eni|cI*UIvKYx?Wa*~jBw<`6L?{&#Z+D%ewDO(PUH#kW=o{kSd#^D4^yxlMECXt z;eukEvaaP<_LisV?B9i}oT9lkil($nchFpz>AmAK$4E1Yk|LY`8%?Z1>mPePa^Zn`}{-!`% zaM+rqkm?3Rz&fywnYjs0?3*RhOXb#=D3;p7YJMBP#nj+F<`s-Hc=4FJqo8#IA;6Zc zFuaYa{0^@Te?-fJOU#U2W^#OiU(QZ2`on&fiY3b318nRm6U5_;BRjhDc+wywC!`Q* z89Ku!AAAgj$mCCFnECwyUrZn4kA+^k%ReJQi4uvTXjHP463=>ndzX2$|Hb^RSNljs kcg4)j9~}97>MsA`39(pz6}>#e>i_@%07*qoM6N<$f^l1B*8l(j literal 0 HcmV?d00001 diff --git a/assets/cross.png b/assets/cross.png new file mode 100644 index 0000000000000000000000000000000000000000..1514d51a3cf1b67e1c5b9ada36f1fd474e2d214a GIT binary patch literal 655 zcmV;A0&x9_P)uEoyT++I zn$b9r%cFfhHe2K68PkBu*@^<$y+7xQ$wJ~;c5aBx$R=xq*41Wo zhwQus_VOgm0hughj}MhOvs#{>Vg09Y8WxjWUJY5YW zJ?&8eG!59Cz=|E%Ns@013KLWOLV)CObIIj_5{>{#k%TEAMs_GbdDV`x-iYsGH z#=Z{USAQA>NY(}X7=3{K8# 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 +} diff --git a/controller_regression_test.go b/controller_regression_test.go new file mode 100644 index 0000000..eb8c791 --- /dev/null +++ b/controller_regression_test.go @@ -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) +} diff --git a/controller_test.go b/controller_test.go new file mode 100644 index 0000000..22fd945 --- /dev/null +++ b/controller_test.go @@ -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) +} diff --git a/edge_test.go b/edge_test.go new file mode 100644 index 0000000..b1aa126 --- /dev/null +++ b/edge_test.go @@ -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") + } +} diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..381f003 --- /dev/null +++ b/logging.go @@ -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() +} diff --git a/logging_test.go b/logging_test.go new file mode 100644 index 0000000..6adf1cc --- /dev/null +++ b/logging_test.go @@ -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) + } +} diff --git a/main.go b/main.go index b8a9443..5ceb367 100644 --- a/main.go +++ b/main.go @@ -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)) - } - 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 +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)) } - 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 - } - return nil, fmt.Errorf("Process32Next failed: %w", e) + if err := emit(c.Step()); err != nil { + return err + } + 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) - os.Exit(1) + return err } - 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...) + 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 } - - logf("autoPriority started (mem=%s, game-mem=%s, interval=%s, dry-run=%v)", - formatMemSize(mem), formatMemSize(gameMem), *interval, *dryRun) - - if !*dryRun { + if !o.dryRun { pid := uint32(os.Getpid()) - h, err := openProc(pid, ProcessSetInformation) - if err == nil { - err = setPrio(h, pid, PriorityClassIdle) + h, e := openProc(pid, ProcessSetInformation) + if e == nil { + e = 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") + if e != nil { + if err = emit(scanReport{Errors: []processError{{Name: "autoPriority.exe", PID: pid, Description: fmt.Sprintf("Собственный приоритет IDLE не установлен: %v", e)}}}); err != nil { + return err + } } } - - 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 + 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 } - - 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() { 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 } - - 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") - } + if err == nil { + err = runApplication(o) } - - scan() - for { - select { - case <-stop: - logf("received shutdown signal") - return - case <-ticker.C: - scan() - } + if err != nil { + fmt.Fprintf(os.Stderr, "autoPriority: %v\n", err) + showError(err) + os.Exit(1) } } diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..e43fa48 --- /dev/null +++ b/policy.go @@ -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) + } +} diff --git a/requested_behavior_test.go b/requested_behavior_test.go new file mode 100644 index 0000000..805fca6 --- /dev/null +++ b/requested_behavior_test.go @@ -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) + } + } + } +} diff --git a/rotate.go b/rotate.go new file mode 100644 index 0000000..9815382 --- /dev/null +++ b/rotate.go @@ -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 +} diff --git a/rotate_test.go b/rotate_test.go new file mode 100644 index 0000000..7566cac --- /dev/null +++ b/rotate_test.go @@ -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") + } +} diff --git a/tray_assets.go b/tray_assets.go new file mode 100644 index 0000000..6f5ed80 --- /dev/null +++ b/tray_assets.go @@ -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 +} diff --git a/tray_integration_test.go b/tray_integration_test.go new file mode 100644 index 0000000..39bc706 --- /dev/null +++ b/tray_integration_test.go @@ -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") + } +} diff --git a/tray_native_test.go b/tray_native_test.go new file mode 100644 index 0000000..72a225e --- /dev/null +++ b/tray_native_test.go @@ -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 +} diff --git a/tray_test.go b/tray_test.go new file mode 100644 index 0000000..949ba09 --- /dev/null +++ b/tray_test.go @@ -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) + } + } +} diff --git a/tray_windows.go b/tray_windows.go new file mode 100644 index 0000000..2360dc3 --- /dev/null +++ b/tray_windows.go @@ -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) +} diff --git a/windows.go b/windows.go new file mode 100644 index 0000000..98c5b7c --- /dev/null +++ b/windows.go @@ -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 + } +} diff --git a/windows_native_test.go b/windows_native_test.go new file mode 100644 index 0000000..3323a9e --- /dev/null +++ b/windows_native_test.go @@ -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) + } +} diff --git a/windows_test.go b/windows_test.go new file mode 100644 index 0000000..e77b104 --- /dev/null +++ b/windows_test.go @@ -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") + } +}