feat: add tray control, priority restoration and session logging

This commit is contained in:
lzrdblzzrd committed 2026-10-04 19:00:59 +03:00
1 parent 4f8f6218ad
commit 4880aa46dc
25 files changed
+3072 -676

No files matched your search

+92 -87
View File
@@ -1,106 +1,111 @@
# autoPriority
Отслеживает использование памяти процессами в Windows и автоматически управляет их приоритетом: ресурсоемким процессам назначает HIGH, а менее ресурсоемким снижает приоритет до NORMAL, только если он был выше. При превышении повышенного порога может включать **игровой режим**, назначая обнаруженному процессу HIGH, а всем остальным — IDLE.
Windows-утилита для управления приоритетами процессов по расходу оперативной памяти. Работает из одного EXE, без установки и службы.
**Только для Windows.**
## Запуск и выход
## Принцип работы
Запустите `autoPriority.exe`. Первая проверка процессов выполняется при запуске, последующие — через заданный интервал, по умолчанию раз в минуту.
### Обычный режим
Значок в трее открывает меню с пунктом **«Выход»** по левой, правой, средней или дополнительной кнопке мыши. Меню доступно и с клавиатуры. При выходе программа останавливает проверки, восстанавливает доступные для восстановления приоритеты и убирает значок. Консольная сборка также поддерживает Ctrl+C.
Через заданный интервал программа проверяет все запущенные процессы:
В одном сеансе Windows работает один экземпляр. Повторный запуск завершается, не затрагивая работающий экземпляр и его журнал. Программа не настраивает автозапуск и не запрашивает повышение прав.
| RSS относительно `-mem` | Текущий приоритет | Действие |
## Как назначаются приоритеты
Порог памяти применяется к рабочему набору процесса (RSS).
| Режим и условие | Приоритет |
|---|---|
| Обычный режим: RSS не меньше `-mem` | HIGH |
| Обычный режим: RSS меньше `-mem`, текущий приоритет выше NORMAL | NORMAL |
| Обычный режим: RSS меньше `-mem`, текущий приоритет NORMAL, BELOW_NORMAL или IDLE | Без изменения |
| Игровой режим: процесс достиг `-game-mem` | HIGH |
| Игровой режим: остальные процессы | IDLE |
**Игровой режим не распознаёт игры.** Его может включить любой процесс, достигший `-game-mem`, в том числе браузер. Такой процесс остаётся участником режима до завершения, даже если расход памяти уменьшился. Если участников несколько, режим заканчивается после выхода последнего.
После окончания игрового режима программа снимает временное понижение и применяет обычные правила. При штатном выходе самой autoPriority она восстанавливает собственные изменения, включая повышения вне игрового режима. Замеченное внешнее изменение приоритета становится новой исходной точкой.
## Параметры
| Флаг | По умолчанию | Описание |
|---|---|---|
| ≥ порога | любой, кроме HIGH | → HIGH (запись `PROMOTE` в журнале) |
| ≥ порога | уже HIGH | пропустить |
| < порога | ABOVE_NORMAL, HIGH или REALTIME | → NORMAL (запись `DEMOTE` в журнале) |
| < порога | NORMAL, BELOW_NORMAL или IDLE | пропустить |
| `-mem` | `512M` | Обычный порог памяти: положительное целое число с необязательным суффиксом K/M/G |
| `-game-mem` | `2G` | Порог игрового режима: больше `-mem`; `0` отключает режим |
| `-interval` | `1m` | Интервал проверок; значения меньше 10 секунд ограничиваются 10 секундами |
| `-dry-run` | `false` | Запись предполагаемых изменений в журнал без изменения приоритетов |
### Игровой режим (`-game-mem`)
Когда любой процесс достигает или превышает порог `-game-mem`:
1. Этому процессу назначается **HIGH** (запись `GAME` в журнале).
2. Всем остальным процессам назначается **IDLE** с сохранением исходных приоритетов.
3. В журнал добавляется запись `GAME MODE ON`.
Когда все такие процессы завершаются:
1. Всем процессам, приоритет которых был снижен в игровом режиме, **возвращается исходный приоритет** (запись `RESTORE` в журнале).
2. В журнал добавляется запись `GAME MODE OFF`.
3. Возобновляется обычный режим, и приоритеты пересчитываются по правилам `-mem`.
Если изменить приоритет процесса невозможно, например из-за античита или системных ограничений, процесс добавляется во внутренний список исключений и больше не изменяется (запись `BLOCK` в журнале). Его RSS продолжает измеряться, поэтому он может включить игровой режим. Процессы, память которых прочитать не удалось, пропускаются до следующей проверки.
При штатном завершении программа несколько раз пытается восстановить приоритеты всех процессов, измененных в игровом режиме. Если Windows отклоняет восстановление, в журнал записывается предупреждение.
В одном сеансе Windows может работать только один экземпляр программы. Второй экземпляр завершается, не прерывая работу первого.
## Сборка
Требуется Go 1.26 или новее.
```
# Обычная сборка с окном консоли
go build -o autopriority.exe .
# Фоновая сборка без консоли, минимальный размер
go build -trimpath -buildvcs=false -gcflags="all=-l" -ldflags="-H=windowsgui -s -w -buildid=" -o autopriority.exe .
```
## Использование
```
autopriority [flags]
```
| Флаг | Значение по умолчанию | Описание |
|-------------|---------------|----------------------------------|
| `-mem` | 512M | Порог памяти, например 512M, 1G или 2048M |
| `-game-mem` | 2G | Порог игрового режима, например 2G или 4G. Должен быть больше `-mem`. 0 отключает режим |
| `-interval` | 1 минута | Интервал проверки, минимум 10 секунд |
| `-dry-run` | false | Только записывать решения в журнал, не менять приоритеты |
Примеры:
```
# Порог 1 ГБ, проверка каждые 30 секунд
autopriority -mem=1G -interval=30s
# Запуск со значениями по умолчанию: порог 512M, игровой порог 2G
autopriority
# Обычный порог 512M, игровой порог 4G
autopriority -mem=512M -game-mem=4G
# Пробный запуск: записывать решения, ничего не изменяя
autopriority -dry-run
```text
autoPriority.exe
autoPriority.exe -mem=1G -game-mem=4G -interval=30s
autoPriority.exe -game-mem=0
autoPriority.exe -dry-run
```
## Журнал
Журнал всегда записывается в `%TEMP%\autopriority.log`. При каждом запуске создается новый файл, а предыдущий удаляется.
Файл журнала находится во временной папке Windows: **`%TEMP%\autopriority.log`**. Путь определяется через `os.TempDir`.
Типы записей:
При каждом запуске журнал очищается, архив предыдущей сессии удаляется. В текущей сессии сохраняются основной файл и один архив `autopriority.log.1`, каждый размером до 4 МиБ. Слишком длинная запись сокращается с отметкой.
| Префикс | Значение |
|---|---|
| `PROMOTE` | Приоритет повышен до HIGH в обычном режиме |
| `DEMOTE` | Приоритет снижен до NORMAL в обычном режиме |
| `GAME DETECT` | Впервые обнаружен процесс, превысивший порог `-game-mem` |
| `GAME` | Процессу назначен HIGH или IDLE в игровом режиме |
| `GAME MODE ON` | Игровой режим включен |
| `GAME MODE OFF` | Игровой режим выключен, приоритеты восстановлены |
| `BLOCK` | Ошибка OpenProcess или SetPriorityClass, процесс добавлен в список исключений |
| `SKIP` | Не удалось прочитать сведения о процессе, попытка повторится при следующей проверке |
| `RESTORE` | Восстановлен исходный приоритет при выходе из игрового режима или завершении программы |
| `[DRY-RUN]` | Приоритет был бы изменен в пробном режиме |
Изменения выводятся ASCII-таблицей:
## Автозапуск
```text
Процесс | PID | Память | Повышено | Понижено
```
Нажмите **Win+R**, введите `shell:startup` и нажмите Enter. Поместите ярлык `autopriority.exe` в открывшуюся папку.
При включении игрового режима его участники идут первыми, фоновые процессы — ниже. Уже установленный HIGH отмечается как «уже HIGH». Если изменить приоритет участника не удалось, его строка содержит отметку «в блоклисте».
## Зависимости
Ошибки выводятся отдельным блоком «ОШИБКИ»:
Отсутствуют. Программа напрямую вызывает Windows API (`kernel32`, `psapi`) через syscall.
```text
Процесс | PID | Описание ошибки
```
Под таблицей нет дублирующих сообщений. Режим `-dry-run` явно помечает записи как план. При ошибке записи журнала программа завершает работу с попыткой восстановления приоритетов.
## Блоклист
После ошибки чтения или изменения приоритета процесс попадает в блоклист. Повторных попыток для него нет, в том числе при выходе; остальные процессы обрабатываются независимо. Блоклист хранится в памяти и очищается при перезапуске autoPriority.
Если время создания доступно, блокировка привязана к PID и времени создания. Другой экземпляр с тем же PID обрабатывается отдельно. Если Windows запрещает даже чтение процесса, запись снимается при исчезновении PID из снимка либо смене имени или родителя. Замену недоступного процесса другим с тем же PID, именем и родителем между двумя снимками надёжно различить нельзя.
Процесс, доступный для чтения, но недоступный для изменения, может включать игровой режим по расходу памяти.
## Ограничения
- Восстановление незаблокированного процесса выполняется один раз. Если Windows отказывает в доступе, программа сообщает о невосстановленных приоритетах.
- Принудительное завершение, сбой ОС или отключение питания не позволяют гарантировать восстановление.
- Между чтением и записью приоритета другая программа может изменить его значение.
- Наследованный дочерними процессами IDLE определяется по родству и времени создания. Это эвристика: она может затронуть явно заданный ребёнку IDLE и не обнаружить цепочку, если родитель завершился между проверками.
- Программа не обходит защиту процессов, не включает SeDebugPrivilege и не гарантирует совместимость с античитом или прирост FPS.
## Сборка и проверки
Для сборки нужны Windows и Go 1.26 или новее. Сторонние Go-модули и CGO не требуются.
```text
go build -trimpath -buildvcs=false -ldflags="-H=windowsgui -s -w -buildid=" -o autoPriority.exe .
go test ./...
go vet ./...
```
Для консольной сборки уберите `-H=windowsgui`.
Обычные тесты используют синтетические процессы. Нативные проверки включаются переменными окружения:
- `AP_NATIVE_PROCESSES=1`: проверка Windows API только на специально созданных дочерних процессах.
- `AP_NATIVE_GUI=1`: проверка собственного значка и меню трея; может кратко переключить фокус.
Нативные тесты не запускают системный сканер программы. Запускайте их вне защищённой игры.
## Иконки
Программа использует `chart_bar_edit` для трея и `cross` для пункта «Выход». PNG встроены в EXE.
Silk 1.3, **Mark James**, Creative Commons Attribution 2.5.
- Автор: http://www.famfamfam.com/lab/icons/silk/
- Источник: https://github.com/legacy-icons/famfamfam-silk
- Ревизия: `d6056f5c28e46c28403888ab7c013aced3928ec8`.
- Лицензия и контрольные суммы: `assets/SILK-LICENSE.md`, `assets/SILK-SOURCE.json`.
+84
View File
@@ -0,0 +1,84 @@
//go:build windows
package main
import (
"context"
"errors"
"flag"
"io"
"syscall"
"testing"
"time"
)
func TestOptionsValidation(t *testing.T) {
for _, args := range [][]string{{"-mem=0"}, {"-mem=1.5G"}, {"-game-mem=128M"}, {"-interval=bad"}, {"extra"}, {"-unknown"}} {
if _, err := readOptions(args, io.Discard); err == nil {
t.Fatalf("accepted %v", args)
}
}
o, err := readOptions([]string{"-interval=1ms", "-game-mem=0"}, io.Discard)
if err != nil || o.mem != 512<<20 || o.gameMem != 0 || o.interval != 10*time.Second {
t.Fatal(o, err)
}
if _, err = readOptions([]string{"-h"}, io.Discard); !errors.Is(err, flag.ErrHelp) {
t.Fatal(err)
}
}
func TestRunControllerCancellationRestores(t *testing.T) {
f := newFake()
p := f.add(10, 150, PriorityClassBelowNormal)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
err := runController(ctx, options{mem: 100, gameMem: 200, interval: time.Minute}, f, func(scanReport) error {
calls++
if calls == 1 {
assertPriority(t, p, PriorityClassHigh)
cancel()
}
return nil
})
if err != nil {
t.Fatal(err)
}
assertPriority(t, p, PriorityClassBelowNormal)
assertClosed(t, f)
if calls != 2 {
t.Fatal("shutdown report missing")
}
}
func TestRunControllerLogFailureStillRestores(t *testing.T) {
f := newFake()
p := f.add(10, 150, PriorityClassNormal)
sentinel := errors.New("disk failure")
err := runController(context.Background(), options{mem: 100, interval: time.Minute}, f, func(scanReport) error { return sentinel })
if !errors.Is(err, sentinel) {
t.Fatal(err)
}
assertPriority(t, p, PriorityClassNormal)
assertClosed(t, f)
}
func TestCancelledBeforeStartupDoesNotScan(t *testing.T) {
f := newFake()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := runController(ctx, options{mem: 100, interval: time.Minute}, f, func(scanReport) error { return nil }); err != nil {
t.Fatal(err)
}
if f.snapshots != 0 {
t.Fatal("cancelled startup still scanned processes")
}
}
func TestWaitResultContract(t *testing.T) {
for _, item := range []struct {
code uintptr
alive, bad bool
}{{0, false, false}, {0x102, true, false}, {0xffffffff, false, true}, {0x80, false, true}} {
alive, err := decodeWaitResult(item.code, syscall.Errno(6))
if alive != item.alive || (err != nil) != item.bad {
t.Fatalf("bad wait decoding for %#x", item.code)
}
}
}
+37
View File
@@ -0,0 +1,37 @@
Original readme (from author of the icons)
----------
Silk icon set 1.3
_________________________________________
Mark James
http://www.famfamfam.com/lab/icons/silk/
_________________________________________
This work is licensed under a
Creative Commons Attribution 2.5 License.
[ http://creativecommons.org/licenses/by/2.5/ ]
This means you may use it for any purpose,
and make any changes you like.
All I ask is that you include a link back
to this page in your credits.
Are you using this icon set? Send me an email
(including a link or picture if available) to
mjames@gmail.com
Any other questions about this icon set please
contact mjames@gmail.com
About the rest (all this repository but the icons)
----------
All the content of this repository (excepted the icon pack)
is licensed under the [MIT license](http://opensource.org/licenses/MIT).
Though, it is just composed a few trivial json files and a Readme.
+20
View File
@@ -0,0 +1,20 @@
{
"repository": "legacy-icons/famfamfam-silk",
"commit": "d6056f5c28e46c28403888ab7c013aced3928ec8",
"files": [
{
"name": "chart_bar_edit.png",
"path": "src/chart_bar_edit.png",
"bytes": 754,
"sha256": "f52dca6b882152f3a4f2459ea8a02a75a6d925bf18082cae31bb0ecc254fb9eb",
"identical_to_previous_asset": true
},
{
"name": "cross.png",
"path": "src/cross.png",
"bytes": 655,
"sha256": "d04ecfc93ff86c44f6fc39e35945e3d8a7648ba8fcd97a2635920df2e88893b3",
"identical_to_previous_asset": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

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