From 4600cc0862885cd5184330b26c15d64fc22a3af5 Mon Sep 17 00:00:00 2001 From: lzrdblzzrd Date: Sun, 4 Oct 2026 19:30:54 +0300 Subject: [PATCH] refactor: single-file stealth build without tray, tables or tests Keep the fixed scan and priority core in one main.go: PID+creation identity, query-only snapshots with write-only upgrades, single-attempt blocklists, external-change baselines, inherited-IDLE adoption and plain %TEMP% line logging. Drop the tray UI, icons, pretty tables, rotation and test scaffolding. --- README.md | 127 ++--- app_test.go | 84 --- assets/SILK-LICENSE.md | 37 -- assets/SILK-SOURCE.json | 20 - assets/chart_bar_edit.png | Bin 754 -> 0 bytes assets/cross.png | Bin 655 -> 0 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 | 994 ++++++++++++++++++++++++++++++---- main_test.go | 63 --- 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 - 26 files changed, 944 insertions(+), 3128 deletions(-) delete mode 100644 app_test.go delete mode 100644 assets/SILK-LICENSE.md delete mode 100644 assets/SILK-SOURCE.json delete mode 100644 assets/chart_bar_edit.png delete mode 100644 assets/cross.png delete mode 100644 controller.go delete mode 100644 controller_regression_test.go delete mode 100644 controller_test.go delete mode 100644 edge_test.go delete mode 100644 logging.go delete mode 100644 logging_test.go delete mode 100644 main_test.go delete mode 100644 policy.go delete mode 100644 requested_behavior_test.go delete mode 100644 rotate.go delete mode 100644 rotate_test.go delete mode 100644 tray_assets.go delete mode 100644 tray_integration_test.go delete mode 100644 tray_native_test.go delete mode 100644 tray_test.go delete mode 100644 tray_windows.go delete mode 100644 windows.go delete mode 100644 windows_native_test.go delete mode 100644 windows_test.go diff --git a/README.md b/README.md index debd753..8f899ac 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,8 @@ # autoPriority -Windows-утилита для управления приоритетами процессов по расходу оперативной памяти. Работает из одного EXE, без установки и службы. +Тихая Windows-утилита: назначает приоритеты процессам по расходу оперативной памяти. Один файл `main.go` собирается в один EXE без установки, службы, иконок и сторонних модулей. Программа не показывает окон и значков — только процесс в диспетчере задач и строки в журнале. -## Запуск и выход - -Запустите `autoPriority.exe`. Первая проверка процессов выполняется при запуске, последующие — через заданный интервал, по умолчанию раз в минуту. - -Значок в трее открывает меню с пунктом **«Выход»** по левой, правой, средней или дополнительной кнопке мыши. Меню доступно и с клавиатуры. При выходе программа останавливает проверки, восстанавливает доступные для восстановления приоритеты и убирает значок. Консольная сборка также поддерживает Ctrl+C. - -В одном сеансе Windows работает один экземпляр. Повторный запуск завершается, не затрагивая работающий экземпляр и его журнал. Программа не настраивает автозапуск и не запрашивает повышение прав. - -## Как назначаются приоритеты - -Порог памяти применяется к рабочему набору процесса (RSS). - -| Режим и условие | Приоритет | -|---|---| -| Обычный режим: RSS не меньше `-mem` | HIGH | -| Обычный режим: RSS меньше `-mem`, текущий приоритет выше NORMAL | NORMAL | -| Обычный режим: RSS меньше `-mem`, текущий приоритет NORMAL, BELOW_NORMAL или IDLE | Без изменения | -| Игровой режим: процесс достиг `-game-mem` | HIGH | -| Игровой режим: остальные процессы | IDLE | - -**Игровой режим не распознаёт игры.** Его может включить любой процесс, достигший `-game-mem`, в том числе браузер. Такой процесс остаётся участником режима до завершения, даже если расход памяти уменьшился. Если участников несколько, режим заканчивается после выхода последнего. - -После окончания игрового режима программа снимает временное понижение и применяет обычные правила. При штатном выходе самой autoPriority она восстанавливает собственные изменения, включая повышения вне игрового режима. Замеченное внешнее изменение приоритета становится новой исходной точкой. - -## Параметры - -| Флаг | По умолчанию | Описание | -|---|---|---| -| `-mem` | `512M` | Обычный порог памяти: положительное целое число с необязательным суффиксом K/M/G | -| `-game-mem` | `2G` | Порог игрового режима: больше `-mem`; `0` отключает режим | -| `-interval` | `1m` | Интервал проверок; значения меньше 10 секунд ограничиваются 10 секундами | -| `-dry-run` | `false` | Запись предполагаемых изменений в журнал без изменения приоритетов | +## Запуск ```text autoPriority.exe @@ -42,70 +11,62 @@ autoPriority.exe -game-mem=0 autoPriority.exe -dry-run ``` +| Флаг | По умолчанию | Описание | +|---|---|---| +| `-mem` | `512M` | Обычный порог памяти: положительное целое число с суффиксом K/M/G | +| `-game-mem` | `2G` | Порог тяжёлого режима: больше `-mem`; `0` отключает режим | +| `-interval` | `1m` | Интервал проверок; меньше 10 секунд ограничивается 10 секундами | +| `-dry-run` | `false` | Только запись плана в журнал без изменения приоритетов | + +Первая проверка выполняется при запуске, дальше — по интервалу. В одном сеансе Windows работает один экземпляр; повторный запуск завершается, не трогая работающий. Программа не настраивает автозапуск и не запрашивает повышение прав. + +Остановка — завершением процесса в диспетчере задач или сигналом. При штатном завершении по Ctrl+C / SIGTERM программа один раз восстанавливает собственные изменения. Принудительное завершение восстановления не гарантирует. + +## Как назначаются приоритеты + +Порог применяется к рабочему набору процесса (RSS). + +| Условие | Приоритет | +|---|---| +| RSS не меньше `-mem` | HIGH | +| RSS меньше `-mem`, текущий приоритет выше NORMAL | NORMAL | +| RSS меньше `-mem`, приоритет NORMAL, BELOW_NORMAL или IDLE | Без изменения | +| Процесс достиг `-game-mem` | HIGH, остальным IDLE | + +`-game-mem` — просто порог, не распознавание игр: режим может включить любой тяжёлый процесс. Участник остаётся в режиме до завершения, даже если расход памяти упал. После выхода последнего участника временное понижение снимается и применяются обычные правила. При выходе самой программы восстанавливаются её собственные изменения. Чужое изменение приоритета становится новой исходной точкой, а не перезаписывается. + +После отказа процесс попадает в блоклист в памяти: повторных попыток нет, остальные обрабатываются независимо. Блоклист очищается при перезапуске. Запись снимается, если PID исчез или его занял другой экземпляр. + ## Журнал -Файл журнала находится во временной папке Windows: **`%TEMP%\autopriority.log`**. Путь определяется через `os.TempDir`. - -При каждом запуске журнал очищается, архив предыдущей сессии удаляется. В текущей сессии сохраняются основной файл и один архив `autopriority.log.1`, каждый размером до 4 МиБ. Слишком длинная запись сокращается с отметкой. - -Изменения выводятся ASCII-таблицей: +Обычные строки во временной папке Windows: **`%TEMP%\autopriority.log`**. При каждом запуске файл очищается. Формат строк: ```text -Процесс | PID | Память | Повышено | Понижено +[04.10.2026 18:14:53] PROMOTE Game.exe (PID 4321) RSS=5.8GB, NORMAL -> HIGH +[04.10.2026 18:14:53] GAME Browser.exe (PID 5678) RSS=740MB, HIGH -> IDLE +[04.10.2026 18:14:53] BLOCK svchost.exe (PID 1448): IDLE: OpenProcess(1448) failed: Access is denied. (повторов не будет) +[04.10.2026 18:14:53] SKIP System (PID 4): ... (повторов не будет) ``` -При включении игрового режима его участники идут первыми, фоновые процессы — ниже. Уже установленный HIGH отмечается как «уже HIGH». Если изменить приоритет участника не удалось, его строка содержит отметку «в блоклисте». - -Ошибки выводятся отдельным блоком «ОШИБКИ»: - -```text -Процесс | PID | Описание ошибки -``` - -Под таблицей нет дублирующих сообщений. Режим `-dry-run` явно помечает записи как план. При ошибке записи журнала программа завершает работу с попыткой восстановления приоритетов. - -## Блоклист - -После ошибки чтения или изменения приоритета процесс попадает в блоклист. Повторных попыток для него нет, в том числе при выходе; остальные процессы обрабатываются независимо. Блоклист хранится в памяти и очищается при перезапуске autoPriority. - -Если время создания доступно, блокировка привязана к PID и времени создания. Другой экземпляр с тем же PID обрабатывается отдельно. Если Windows запрещает даже чтение процесса, запись снимается при исчезновении PID из снимка либо смене имени или родителя. Замену недоступного процесса другим с тем же PID, именем и родителем между двумя снимками надёжно различить нельзя. - -Процесс, доступный для чтения, но недоступный для изменения, может включать игровой режим по расходу памяти. +Режим `-dry-run` помечает строки `[DRY-RUN]`. ## Ограничения -- Восстановление незаблокированного процесса выполняется один раз. Если Windows отказывает в доступе, программа сообщает о невосстановленных приоритетах. -- Принудительное завершение, сбой ОС или отключение питания не позволяют гарантировать восстановление. -- Между чтением и записью приоритета другая программа может изменить его значение. -- Наследованный дочерними процессами IDLE определяется по родству и времени создания. Это эвристика: она может затронуть явно заданный ребёнку IDLE и не обнаружить цепочку, если родитель завершился между проверками. -- Программа не обходит защиту процессов, не включает SeDebugPrivilege и не гарантирует совместимость с античитом или прирост FPS. +- Windows может запретить чтение или изменение приоритета — программа пишет строку в журнал и больше не трогает процесс. +- Восстановление выполняется один раз; о невосстановленном сообщается в журнале. +- Наследованный дочерними процессами IDLE определяется по родству и времени создания. Это эвристика. +- Программа не обходит защиту процессов и не гарантирует совместимость с античитом. -## Сборка и проверки +## Сборка -Для сборки нужны Windows и Go 1.26 или новее. Сторонние Go-модули и CGO не требуются. +Нужны Windows и Go 1.26 или новее, без сторонних модулей и CGO. Скрытый вариант без окна: ```text go build -trimpath -buildvcs=false -ldflags="-H=windowsgui -s -w -buildid=" -o autoPriority.exe . -go test ./... -go vet ./... ``` -Для консольной сборки уберите `-H=windowsgui`. +Консольный вариант для отладки: уберите `-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`. +```text +go vet ./... +``` diff --git a/app_test.go b/app_test.go deleted file mode 100644 index 8159059..0000000 --- a/app_test.go +++ /dev/null @@ -1,84 +0,0 @@ -//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 deleted file mode 100644 index 0734049..0000000 --- a/assets/SILK-LICENSE.md +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 649cb4e..0000000 --- a/assets/SILK-SOURCE.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index df64d97e615935bd6fc9d39eea5442b2c324b0e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/assets/cross.png b/assets/cross.png deleted file mode 100644 index 1514d51a3cf1b67e1c5b9ada36f1fd474e2d214a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 deleted file mode 100644 index eb8c791..0000000 --- a/controller_regression_test.go +++ /dev/null @@ -1,288 +0,0 @@ -//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 deleted file mode 100644 index 22fd945..0000000 --- a/controller_test.go +++ /dev/null @@ -1,204 +0,0 @@ -//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 deleted file mode 100644 index b1aa126..0000000 --- a/edge_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//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 deleted file mode 100644 index 381f003..0000000 --- a/logging.go +++ /dev/null @@ -1,153 +0,0 @@ -//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 deleted file mode 100644 index 6adf1cc..0000000 --- a/logging_test.go +++ /dev/null @@ -1,57 +0,0 @@ -//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 5ceb367..8265bea 100644 --- a/main.go +++ b/main.go @@ -3,136 +3,942 @@ package main import ( - "context" - "errors" "flag" "fmt" - "io" + "math" "os" "os/signal" "path/filepath" + "sort" + "strconv" + "strings" "syscall" "time" + "unsafe" ) -type options struct { - mem, gameMem uint64 - interval time.Duration - dryRun bool +const ( + CreateToolhelp32SnapshotProcess = 0x00000002 + ProcessSetInformation = 0x00000200 + ProcessQueryLimitedInformation = 0x00001000 + processSynchronize = 0x00100000 + + 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 } -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 - } - if fs.NArg() != 0 { - return o, fmt.Errorf("unexpected positional arguments") - } - 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") - } - o.gameMem, err = parseMemSize(*gameMem) - if err != nil { - return o, fmt.Errorf("invalid -game-mem: %w", err) - } - if o.gameMem != 0 && o.gameMem <= o.mem { - return o, fmt.Errorf("-game-mem must be greater than -mem, or 0") - } - if o.interval < 10*time.Second { - o.interval = 10 * time.Second - } - return o, nil +// Порядок PagefileUsage/PeakPagefileUsage соответствует ABI Windows. +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 } -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)) + +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 +} + +type processState struct { + info procInfo + original, last uint32 + idleSince uint64 + changed, game, overlay bool + gameBase uint32 +} + +type changeKind uint8 + +const ( + normalChange changeKind = iota + gameChange + restoringChange +) + +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") + procWaitSingle = k32.NewProc("WaitForSingleObject") +) + +var logf func(string, ...any) + +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 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 processAlive(h syscall.Handle) (bool, error) { + r, _, err := procWaitSingle.Call(uintptr(h), 0) + return decodeWaitResult(r, err) +} + +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 } - }() - ticker := time.NewTicker(o.interval) - defer ticker.Stop() + 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 { + if e == syscall.Errno(ErrorNoMoreFiles) { + return nil, nil + } + return nil, fmt.Errorf("Process32First failed: %w", e) + } + + var out []procInfo for { - if ctx.Err() != nil { - return nil - } - if err := emit(c.Step()); err != nil { - return err - } - select { - case <-ctx.Done(): - return nil - case <-ticker.C: + 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 +} + +type app struct { + mem, gameMem uint64 + dryRun bool + gameMode bool + self uint32 + states map[processKey]*processState + blocked map[processKey]bool + unreadable map[uint32]procInfo + lastScan []procInfo + nameBuf [32768]uint16 +} + +func newApp(mem, gameMem uint64, dryRun bool) *app { + return &app{ + mem: mem, gameMem: gameMem, dryRun: dryRun, + self: uint32(os.Getpid()), + states: make(map[processKey]*processState), + blocked: make(map[processKey]bool), + unreadable: make(map[uint32]procInfo), + } } -func runApplication(o options) (result error) { - mutex, exists, err := singleInstance() + +func (a *app) ordered() []*processState { + list := make([]*processState, 0, len(a.states)) + for _, s := range a.states { + list = append(list, s) + } + sort.Slice(list, func(i, j int) bool { + x, y := list[i], list[j] + if x.game != y.game { + return x.game + } + if x.info.PID != y.info.PID { + return x.info.PID < y.info.PID + } + return x.info.Key.Created < y.info.Key.Created + }) + return list +} + +func (a *app) getPriority(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 +} + +// setPriority открывает процесс только для записи и проверяет, +// что PID не занят другим экземпляром. +func (a *app) setPriority(p *procInfo, target uint32) error { + h, err := openProc(p.PID, ProcessQueryLimitedInformation|ProcessSetInformation) if err != nil { return err } - defer closeH(mutex) - if exists { - fmt.Fprintln(os.Stderr, "autoPriority: already running") - return nil - } - logFile, err := openRotatingLog(filepath.Join(os.TempDir(), "autopriority.log"), 4<<20) + defer closeH(h) + created, err := processCreated(h) if err != nil { return err } - defer func() { result = errors.Join(result, logFile.Close()) }() - emit := func(r scanReport) error { return logFile.Write(formatReport(r, time.Now())) } - if err = emit(scanReport{Title: fmt.Sprintf("autoPriority: mem=%s, game-mem=%s, interval=%s", formatMemSize(o.mem), formatMemSize(o.gameMem), o.interval), DryRun: o.dryRun}); err != nil { - return err + if created != p.Key.Created { + return fmt.Errorf("process instance changed; refusing to write") } - if !o.dryRun { - pid := uint32(os.Getpid()) - h, e := openProc(pid, ProcessSetInformation) - if e == nil { - e = setPrio(h, pid, PriorityClassIdle) + return setPrio(h, p.PID, target) +} + +func (a *app) closeInfo(p *procInfo) { + if p.Handle != 0 { + closeH(p.Handle) + p.Handle = 0 + } +} + +func (a *app) inspect(entry procInfo) (procInfo, error) { + h, err := openProc(entry.PID, ProcessQueryLimitedInformation|processSynchronize) + entry.Waitable = err == nil + if err != nil { + h, err = openProc(entry.PID, ProcessQueryLimitedInformation) + } + if err != nil { + return procInfo{}, err + } + good := false + defer func() { + if !good { closeH(h) } - if e != nil { - if err = emit(scanReport{Errors: []processError{{Name: "autoPriority.exe", PID: pid, Description: fmt.Sprintf("Собственный приоритет IDLE не установлен: %v", e)}}}); err != nil { - return err + }() + created, err := processCreated(h) + if err != nil { + return procInfo{}, err + } + if name, e := processName(h, a.nameBuf[:]); e == nil { + entry.Name = name + } + entry.Handle = h + entry.Key = processKey{PID: entry.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) + } + entry.RSS = uint64(m.WorkingSetSize) + entry.Prio, err = a.getPriority(&entry) + if err != nil { + return procInfo{}, err + } + good = true + return entry, nil +} + +func (a *app) alive(s *processState, pids map[uint32]bool) (bool, error) { + if pids != nil { + if !pids[s.info.PID] { + return false, nil + } + for i := range a.lastScan { + p := &a.lastScan[i] + if p.PID == s.info.PID && p.Key != s.info.Key { + return false, 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 + if s.info.Waitable { + return processAlive(s.info.Handle) } - 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) + return true, nil } -func main() { - o, err := readOptions(os.Args[1:], os.Stderr) - if errors.Is(err, flag.ErrHelp) { + +func (s *processState) observe(current uint32) { + if (s.changed || s.overlay) && current != s.last { + // Чужое изменение становится новой исходной точкой. + s.changed = false + s.overlay = false + s.idleSince = 0 + } + s.info.Prio = current + if !s.changed && !s.overlay { + s.original = current + } +} + +func filetimeNow() uint64 { + t := time.Now() + return uint64(t.Unix())*10000000 + uint64(t.Nanosecond()/100) + 116444736000000000 +} + +func inheritedClass(parent uint32) uint32 { + if parent == PriorityClassIdle || parent == PriorityClassBelowNormal { + return parent + } + return PriorityClassNormal +} + +func (a *app) failed(s *processState, target uint32, err error) { + if a.blocked[s.info.Key] { return } - if err == nil { - err = runApplication(o) + a.blocked[s.info.Key] = true + logf("BLOCK %s (PID %d): %s: %v (повторов не будет)", s.info.Name, s.info.PID, prioName(target), err) +} + +func actionName(hasGame bool, target uint32) string { + if hasGame { + return "GAME" } + if target == PriorityClassHigh { + return "PROMOTE" + } + return "DEMOTE" +} + +func (a *app) change(s *processState, target uint32, kind changeKind, hasGame bool) bool { + if a.blocked[s.info.Key] { + return false + } + current, err := a.getPriority(&s.info) if err != nil { + a.failed(s, target, err) + return false + } + s.observe(current) + if kind == normalChange && desiredPriority(s.info.RSS, a.mem, current, false, false) == 0 { + return true + } + if kind == restoringChange && !s.changed && !s.overlay { + return true + } + if current == target { + return true + } + if a.dryRun { + logf("[DRY-RUN] %s %s (PID %d) RSS=%s, %s -> %s", + actionName(hasGame, target), s.info.Name, s.info.PID, + formatMemSize(s.info.RSS), prioName(current), prioName(target)) + return true + } + if err = a.setPriority(&s.info, target); err != nil { + a.failed(s, target, err) + return false + } + if !s.changed { + s.original = current + } + if kind == gameChange && !s.overlay { + s.overlay = true + s.gameBase = current + } + s.changed = true + s.last = target + s.info.Prio = target + if target == PriorityClassIdle { + s.idleSince = filetimeNow() + } else { + s.idleSince = 0 + } + actual, err := a.getPriority(&s.info) + if err != nil { + a.failed(s, target, fmt.Errorf("изменение выполнено, проверка не удалась: %w", err)) + return false + } + s.last = actual + s.info.Prio = actual + s.changed = actual != s.original + if actual != PriorityClassIdle { + s.idleSince = 0 + } + if actual != current { + logf("%s %s (PID %d) RSS=%s, %s -> %s", + actionName(hasGame, target), s.info.Name, s.info.PID, + formatMemSize(s.info.RSS), prioName(current), prioName(actual)) + } + if actual != target { + a.failed(s, target, fmt.Errorf("Windows установила %s вместо %s", prioName(actual), prioName(target))) + return false + } + return true +} + +func (a *app) restoreOverlay(s *processState) bool { + if a.blocked[s.info.Key] { + return false + } + current, err := a.getPriority(&s.info) + if err != nil { + a.failed(s, s.gameBase, err) + return false + } + s.observe(current) + if !s.overlay { + return true + } + if !a.change(s, s.gameBase, restoringChange, false) { + return false + } + s.overlay = false + return true +} + +func (a *app) adoptInherited(fresh map[processKey]bool) { + if a.dryRun { + return + } + byPID := make(map[uint32]*processState, len(a.states)) + for _, s := range a.states { + byPID[s.info.PID] = s + } + for pass := 0; pass < len(a.states); pass++ { + adopted := false + for _, s := range a.states { + if !fresh[s.info.Key] || s.changed || s.overlay || s.game || s.info.Prio != PriorityClassIdle { + continue + } + parent := byPID[s.info.ParentPID] + if parent == nil || parent == s || !fresh[parent.info.Key] || (!parent.changed && !parent.overlay) || parent.last != PriorityClassIdle || parent.info.Prio != PriorityClassIdle || parent.idleSince == 0 { + continue + } + if s.info.Key.Created < parent.idleSince || s.info.Key.Created <= parent.info.Key.Created { + continue + } + s.original = inheritedClass(parent.original) + s.last = PriorityClassIdle + s.changed = s.original != s.last + s.overlay = parent.overlay + s.gameBase = inheritedClass(parent.gameBase) + s.idleSince = s.info.Key.Created + adopted = adopted || s.changed || s.overlay + } + if !adopted { + break + } + } +} + +func (a *app) releaseUnused() { + for key, s := range a.states { + if !s.game && !s.changed && !s.overlay { + a.closeInfo(&s.info) + delete(a.states, key) + } + } +} + +func (a *app) scan() { + entries, err := allProcs() + if err != nil { + logf("process scan error: %v", err) + return + } + pids := make(map[uint32]bool, len(entries)) + for _, e := range entries { + pids[e.PID] = true + } + var fresh []procInfo + for _, entry := range entries { + if entry.PID == 0 || entry.PID == a.self { + continue + } + if b, ok := a.unreadable[entry.PID]; ok && b.Name == entry.Name && b.ParentPID == entry.ParentPID { + continue + } + info, err := a.inspect(entry) + if err != nil { + logf("SKIP %s (PID %d): %v (повторов не будет)", entry.Name, entry.PID, err) + a.unreadable[entry.PID] = entry + continue + } + delete(a.unreadable, entry.PID) + fresh = append(fresh, info) + } + for pid := range a.unreadable { + if !pids[pid] { + delete(a.unreadable, pid) + } + } + a.lastScan = fresh + + known := make(map[uint32]processKey, len(fresh)) + for _, p := range fresh { + known[p.PID] = p.Key + } + for key := range a.blocked { + cur, ok := known[key.PID] + if !pids[key.PID] || (ok && cur != key) { + delete(a.blocked, key) + } + } + live := make(map[processKey]bool, len(fresh)) + for key, s := range a.states { + ok, err := a.alive(s, pids) + if err != nil { + a.failed(s, s.info.Prio, fmt.Errorf("статус процесса: %w", err)) + } + if err == nil && !ok { + a.closeInfo(&s.info) + delete(a.states, key) + } + } + for i := range fresh { + p := &fresh[i] + live[p.Key] = true + if s := a.states[p.Key]; s != nil { + s.info.Name = p.Name + s.info.RSS = p.RSS + s.info.ParentPID = p.ParentPID + s.observe(p.Prio) + a.closeInfo(p) + } else { + a.states[p.Key] = &processState{info: *p, original: p.Prio} + } + } + a.adoptInherited(live) + + for key, s := range a.states { + if live[key] && a.gameMem > 0 && s.info.RSS >= a.gameMem && !s.game { + s.game = true + logf("GAME DETECT %s (PID %d) RSS=%s", s.info.Name, s.info.PID, formatMemSize(s.info.RSS)) + } + } + hasGame := false + for _, s := range a.states { + if s.game { + hasGame = true + break + } + } + if hasGame && !a.gameMode { + logf("GAME MODE ON") + } + if !hasGame && a.gameMode { + a.restoreAll(fresh) + if !a.anyOverlay() { + a.gameMode = false + logf("GAME MODE OFF") + } else { + return + } + } + a.gameMode = hasGame + + gameSeen := make(map[processKey]bool, len(a.states)) + for key, s := range a.states { + if s.game { + gameSeen[key] = true + } + } + for _, s := range a.ordered() { + if !hasGame && s.overlay { + a.restoreOverlay(s) + } + if !live[s.info.Key] { + continue + } + target := desiredPriority(s.info.RSS, a.mem, s.info.Prio, hasGame, s.game) + if target == 0 { + continue + } + if target == s.info.Prio { + if gameSeen[s.info.Key] && s.info.Prio == PriorityClassHigh && !s.changed { + logf("GAME %s (PID %d) RSS=%s уже HIGH", s.info.Name, s.info.PID, formatMemSize(s.info.RSS)) + delete(gameSeen, s.info.Key) + } + continue + } + kind := normalChange + if hasGame { + kind = gameChange + } + if !a.change(s, target, kind, hasGame) && gameSeen[s.info.Key] { + logf("GAME %s (PID %d) в блоклисте, режим включён", s.info.Name, s.info.PID) + delete(gameSeen, s.info.Key) + } else if gameSeen[s.info.Key] && s.info.Prio == target { + delete(gameSeen, s.info.Key) + } + } + a.releaseUnused() +} + +func (a *app) anyOverlay() bool { + for _, s := range a.states { + if s.overlay { + return true + } + } + return false +} + +func (a *app) restoreAll(fresh []procInfo) { + current := make(map[processKey]*procInfo, len(fresh)) + for i := range fresh { + current[fresh[i].Key] = &fresh[i] + } + for _, s := range a.ordered() { + if !s.overlay { + continue + } + if !a.restoreOverlay(s) { + continue + } + s.overlay = false + if p := current[s.info.Key]; p != nil { + p.Prio = s.gameBase + } + } +} + +func (a *app) shutdown() { + if !a.dryRun && len(a.states) > 0 { + entries, err := allProcs() + if err == nil { + var fresh []procInfo + for _, entry := range entries { + if entry.PID == 0 || entry.PID == a.self { + continue + } + if b, ok := a.unreadable[entry.PID]; ok && b.Name == entry.Name && b.ParentPID == entry.ParentPID { + continue + } + info, err := a.inspect(entry) + if err != nil { + continue + } + fresh = append(fresh, info) + } + a.lastScan = fresh + live := make(map[processKey]bool, len(fresh)) + for i := range fresh { + p := &fresh[i] + live[p.Key] = true + if s := a.states[p.Key]; s != nil { + s.info.Name = p.Name + s.info.RSS = p.RSS + s.info.ParentPID = p.ParentPID + s.observe(p.Prio) + a.closeInfo(p) + } else { + a.states[p.Key] = &processState{info: *p, original: p.Prio} + } + } + a.adoptInherited(live) + } else { + logf("final scan error: %v", err) + } + unrestored := 0 + for _, s := range a.ordered() { + if !s.changed || a.blocked[s.info.Key] { + if s.changed { + unrestored++ + } + continue + } + ok, err := a.alive(s, nil) + if err == nil && !ok { + s.changed = false + continue + } + if err != nil { + a.failed(s, s.original, err) + unrestored++ + continue + } + current, err := a.getPriority(&s.info) + if err != nil { + a.failed(s, s.original, err) + unrestored++ + continue + } + s.observe(current) + if !s.changed { + continue + } + if a.change(s, s.original, restoringChange, false) { + s.changed = false + s.overlay = false + } else { + unrestored++ + } + } + if unrestored > 0 { + logf("warning: %d process priorities could not be restored", unrestored) + } + } + for _, s := range a.states { + a.closeInfo(&s.info) + } + a.gameMode = false +} + +func main() { + memStr := flag.String("mem", "512M", "memory threshold (e.g. 512M, 1G)") + gameMemStr := flag.String("game-mem", "2G", "heavy-process threshold; 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) + } + + mutex, exists, err := singleInstance() + if err != nil { fmt.Fprintf(os.Stderr, "autoPriority: %v\n", err) - showError(err) + os.Exit(1) + } + if exists { + closeH(mutex) + fmt.Fprintln(os.Stderr, "autoPriority: already running") + return + } + defer closeH(mutex) + + // Журнал очищается при каждом запуске. + logPath := filepath.Join(os.TempDir(), "autopriority.log") + logFile, err := os.Create(logPath) + if err != nil { + fmt.Fprintf(os.Stderr, "autoPriority: cannot create log: %v\n", err) os.Exit(1) + } + defer logFile.Close() + + logf = func(format string, v ...any) { + fmt.Fprintf(logFile, "[%s] ", time.Now().Format("02.01.2006 15:04:05")) + fmt.Fprintf(logFile, format+"\n", v...) + } + + logf("autoPriority started (mem=%s, game-mem=%s, interval=%s, dry-run=%v)", + formatMemSize(mem), formatMemSize(gameMem), *interval, *dryRun) + + if !*dryRun { + pid := uint32(os.Getpid()) + h, err := openProc(pid, ProcessSetInformation) + if err == nil { + err = setPrio(h, pid, PriorityClassIdle) + closeH(h) + } + if err != nil { + logf("warning: could not set own priority to IDLE: %v", err) + } else { + logf("own priority set to IDLE") + } + } + + a := newApp(mem, gameMem, *dryRun) + + defer func() { + a.shutdown() + 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) + + a.scan() + for { + select { + case <-stop: + logf("received shutdown signal") + return + case <-ticker.C: + a.scan() + } } } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 730ef6e..0000000 --- a/main_test.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build windows - -package main - -import ( - "math" - "testing" -) - -func TestParseMemSize(t *testing.T) { - tests := []struct { - input string - want uint64 - bad bool - }{ - {"512M", 512 * 1024 * 1024, false}, - {" 1g ", 1024 * 1024 * 1024, false}, - {"0", 0, false}, - {"18446744073709551615", math.MaxUint64, false}, - {"17179869183G", 17179869183 * 1024 * 1024 * 1024, false}, - {"17179869184G", 0, true}, - {"", 0, true}, - {"G", 0, true}, - {"1GB", 0, true}, - {"1.5G", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got, err := parseMemSize(tt.input) - if (err != nil) != tt.bad || got != tt.want { - t.Fatalf("parseMemSize(%q) = %d, %v; want %d, bad=%v", tt.input, got, err, tt.want, tt.bad) - } - }) - } -} - -func TestDesiredPriority(t *testing.T) { - const mem = 512 * 1024 * 1024 - tests := []struct { - name string - rss uint64 - current uint32 - gameMode, isGame bool - want uint32 - }{ - {"promote heavy", mem, PriorityClassNormal, false, false, PriorityClassHigh}, - {"keep heavy high", mem, PriorityClassHigh, false, false, PriorityClassHigh}, - {"demote light high", mem - 1, PriorityClassHigh, false, false, PriorityClassNormal}, - {"demote light realtime", mem - 1, PriorityClassRealtime, false, false, PriorityClassNormal}, - {"keep light normal", mem - 1, PriorityClassNormal, false, false, 0}, - {"boost game", 1, PriorityClassIdle, true, true, PriorityClassHigh}, - {"idle non-game", mem * 10, PriorityClassHigh, true, false, PriorityClassIdle}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := desiredPriority(tt.rss, mem, tt.current, tt.gameMode, tt.isGame); got != tt.want { - t.Fatalf("desiredPriority() = %#x, want %#x", got, tt.want) - } - }) - } -} diff --git a/policy.go b/policy.go deleted file mode 100644 index e43fa48..0000000 --- a/policy.go +++ /dev/null @@ -1,97 +0,0 @@ -//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 deleted file mode 100644 index 805fca6..0000000 --- a/requested_behavior_test.go +++ /dev/null @@ -1,102 +0,0 @@ -//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 deleted file mode 100644 index 9815382..0000000 --- a/rotate.go +++ /dev/null @@ -1,108 +0,0 @@ -//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 deleted file mode 100644 index 7566cac..0000000 --- a/rotate_test.go +++ /dev/null @@ -1,73 +0,0 @@ -//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 deleted file mode 100644 index 6f5ed80..0000000 --- a/tray_assets.go +++ /dev/null @@ -1,26 +0,0 @@ -//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 deleted file mode 100644 index 39bc706..0000000 --- a/tray_integration_test.go +++ /dev/null @@ -1,145 +0,0 @@ -//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 deleted file mode 100644 index 72a225e..0000000 --- a/tray_native_test.go +++ /dev/null @@ -1,70 +0,0 @@ -//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 deleted file mode 100644 index 949ba09..0000000 --- a/tray_test.go +++ /dev/null @@ -1,43 +0,0 @@ -//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 deleted file mode 100644 index 2360dc3..0000000 --- a/tray_windows.go +++ /dev/null @@ -1,453 +0,0 @@ -//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 deleted file mode 100644 index 98c5b7c..0000000 --- a/windows.go +++ /dev/null @@ -1,295 +0,0 @@ -//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 deleted file mode 100644 index 3323a9e..0000000 --- a/windows_native_test.go +++ /dev/null @@ -1,146 +0,0 @@ -//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 deleted file mode 100644 index e77b104..0000000 --- a/windows_test.go +++ /dev/null @@ -1,15 +0,0 @@ -//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") - } -}