1

Demote promoted processes when RSS drops below threshold

Previously, once a process was promoted to HIGH, it stayed HIGH forever
even if its RSS dropped well below the -mem threshold. Now promoted
processes are demoted back to NORMAL when RSS < -mem, and removed from
the promoted map.

Also: added TZ.md with full technical specification of the project.
This commit is contained in:
2026-06-23 08:58:11 +03:00
parent ea914101bb
commit da13a6d549
2 changed files with 362 additions and 4 deletions
+361
View File
@@ -0,0 +1,361 @@
# Техническое задание: autoPriority
## Назначение
Программа для Windows, которая автоматически управляет приоритетами процессов на основе их потребления оперативной памяти. Работает в фоне, периодически сканируя все процессы и корректируя их приоритеты CPU.
## Параметры командной строки
| Параметр | По умолчанию | Описание |
|---|---|---|
| `-mem` | `512M` | Порог памяти для повышения приоритета. Процессы с RSS ≥ этого значения повышаются до HIGH |
| `-game-mem` | `4G` | Порог памяти для активации игрового режима. Процессы с RSS ≥ этого значения считаются играми. `0` — игровой режим отключён. Должен быть больше `-mem` |
| `-interval` | `1m` | Интервал сканирования. Минимум 10 секунд |
| `-dry-run` | `false` | Только логирование, без изменения приоритетов |
Формат памяти: число с суффиксом `K`/`M`/`G` (например `512M`, `4G`, `2048M`). Без суффикра — байты.
## Структуры данных
Программа поддерживает четыре основные карты (map), хранящиеся в памяти между сканами:
### `promoted: map[pid → name]`
Процессы, повышенные до HIGH в нормальном режиме. Если RSS процесса падает ниже порога `-mem`, он понижается до NORMAL и удаляется из карты. При выходе из программы все процессы из карты восстанавливаются до NORMAL.
### `blocked: map[pid → name]`
Процессы, для которых не удалось открыть или изменить приоритет (системные процессы, античит). Полностью исключаются из попыток смены приоритета, но их RSS **всё равно измеряется** каждый скан (для определения игрового режима). Удаляются из карты, когда PID больше не существует в списке процессов.
### `gameProcs: map[pid → name]`
Процессы, превысившие `-game-mem` порог. Определяют, активен ли игровой режим. Удаляются, когда процесс умирает (PID исчезает из списка или имя не совпадает).
### `gameSaved: map[pid → {name, originalPriority}]`
Оригинальные приоритеты процессов, пониженных до IDLE в игровом режиме. Используются для восстановления при выключении игрового режима. Заполняется только при успешном понижении приоритета. Каждый PID сохраняется один раз (при первом понижении), чтобы запомнить приоритет, который был **до** игрового режима.
## Запуск программы
1. Разбор флагов командной строки
2. Проверка: если `game-mem > 0` и `game-mem <= mem` — выход с ошибкой
3. Если `interval < 10s` — принудительно установить 10s
4. **Уничтожение других экземпляров:** получить список всех процессов, найти процессы с тем же именем файла, что и у текущего процесса, но с другим PID, и завершить их через `TerminateProcess`
5. Подождать 100мс (ОС освобождает файловые дескрипторы)
6. Удалить старый лог-файл, создать новый (в `%TEMP%\autopriority.log`)
7. Установить собственный приоритет в IDLE
8. Записать в лог параметры запуска
9. Инициализировать пустые карты: `promoted`, `blocked`, `gameProcs`, `gameSaved`
10. Зарегистрировать deferred-функцию восстановления (см. раздел «Завершение»)
11. Выполнить первый скан немедленно
12. Войти в цикл: ждать сигнала завершения ИЛИ тикера интервала, при тике — выполнять скан
## Алгоритм сканирования (двухпроходный)
Каждый скан состоит из двух проходов. **Первый проход** собирает данные о памяти и определяет игровые процессы. **Второй проход** применяет приоритеты, уже зная, активен ли игровой режим. Это необходимо, чтобы игровой режим срабатывал на первом же скане, а не на следующем.
### Предварительный этап: очистка карт
Перед проходами программа получает список всех процессов и строит `pidMap` (pid → name). Затем очищает четыре карты:
- **`promoted`**: если PID исчез или имя не совпадает — удалить (процесс умер или PID перешёл другому процессу)
- **`blocked`**: если PID исчез — удалить (процесс умер, PID может быть переиспользован)
- **`gameProcs`**: если PID исчез или имя не совпадает — удалить
- **`gameSaved`**: если PID исчез или имя не совпадается — удалить
Проверка имени защищает от PID reuse: если процесс умер и его PID получил другой процесс, старая запись удаляется.
### Проход 1: Сбор RSS и определение игровых процессов
Для каждого процесса (кроме себя и PID 0):
1. Открыть процесс с правом `PROCESS_QUERY_LIMITED_INFORMATION` (0x1000). Это узкое право, которое дают почти все процессы, включая защищённые. Использование `PROCESS_QUERY_INFORMATION` (0x0400) приводит к Access Denied для многих пользовательских процессов.
2. Если `OpenProcess` упал:
- Добавить PID в `blocked` (если ещё не там)
- Записать в лог `BLOCK ... (added to exclusion list)`
- **Перейти к следующему процессу** (не добавлять в `procList`)
- Примечание: blocked процессы не попадают в `procList`, но если они были добавлены в `blocked` на предыдущем скане и всё ещё живы, они НЕ измеряются повторно. Это допустимо, потому что blocked процессы не могут менять приоритет.
**Важное исключение:** если процесс УЖЕ в `blocked` (добавлен ранее), то не логировать повторно — просто пропустить.
3. Вызвать `GetProcessMemoryInfo` для получения `WorkingSetSize` (RSS)
4. Если упало — пропустить процесс
5. Закрыть handle
6. Записать `{PID, Name, RSS}` в `procList`
7. Если `game-mem > 0` и `RSS >= game-mem`:
- Добавить PID в `gameProcs` (если ещё не там)
После прохода 1 вычислить:
```
hasGame = (game-mem > 0) AND (len(gameProcs) > 0)
```
### Проход 2: Применение приоритетов
Для каждого процесса из `procList` (в порядке обхода):
Определить `isBlocked`: процесс находится в `blocked`.
#### Вариант A: Процесс — игровой (есть в `gameProcs`)
Если `hasGame` (игровой режим активен):
- Если `isBlocked`: записать в лог `GAME ... — tracked as game (blocked)`, перейти к следующему
- Открыть с `PROCESS_QUERY_LIMITED_INFORMATION`
- Получить текущий приоритет
- Если уже HIGH — пропустить
- Если `dry-run`: записать `would set HIGH (game)`, пропустить
- Иначе: попытаться установить HIGH через `trySetPrio` (см. ниже)
- Успех → `GAME ... -> HIGH`
- Провал → `GAME ... — tracked as game (priority change failed)`
Перейти к следующему процессу.
#### Вариант B: Игровой режим активен, процесс НЕ игровой
Если `isBlocked`: пропустить (не пытаться понизить)
Открыть с `PROCESS_QUERY_LIMITED_INFORMATION`
Получить текущий приоритет
Если уже IDLE — пропустить
Если `dry-run`: записать `would set IDLE (game)`, пропустить
Иначе: попытаться установить IDLE через `trySetPrio`
- Успех → сохранить оригинальный приоритет в `gameSaved` (если ещё не сохранён), удалить из `promoted` (если там был), записать `GAME ... -> IDLE`
- Провал → процесс добавлен в `blocked`, больше не пытаться
Перейти к следующему процессу.
#### Вариант C: Нормальный режим (игровой режим не активен)
Если `isBlocked`: пропустить
Открыть с `PROCESS_QUERY_LIMITED_INFORMATION`
**Если RSS < `-mem`:**
- Получить текущий приоритет
- Если приоритет ABOVE_NORMAL / HIGH / REALTIME → понизить до NORMAL (DEMOTE), удалить из `promoted` (если там был)
- Иначе пропустить
**Если RSS >= `-mem`:**
- Получить текущий приоритет
- Если уже HIGH — пропустить (процесс уже повышен, возможно из `promoted`)
- Если `dry-run`: записать `would set HIGH`, пропустить
- Иначе: попытаться установить HIGH через `trySetPrio`
- Успех → добавить в `promoted`, записать `PROMOTE ... -> HIGH`
- Провал → процесс добавлен в `blocked`
### Финал скана: управление игровым режимом
После прохода 2, если `game-mem > 0`:
Вычислить `newHasGame = len(gameProcs) > 0`
**Если `newHasGame == true` и `gameMode == false`:**
- `gameMode = true`
- Записать `GAME MODE ON`
**Если `newHasGame == false` и `gameMode == true`:**
- `gameMode = false`
- Записать `GAME MODE OFF`
- **Восстановить приоритеты:** для каждого PID в `gameSaved`:
- Проверить, что PID всё ещё существует и имя совпадает (защита от PID reuse)
- Если `dry-run`: записать `[DRY-RUN] RESTORE ... -> originalPriority`
- Иначе: установить оригинальный приоритет через `setPrio` (отдельный `OpenProcess` с `PROCESS_SET_INFORMATION`)
- Успех → `RESTORE ... -> originalPriority`
- Провал → `RESTORE ... error`
- Удалить запись из `gameSaved`
- Очистить `gameSaved` (все записи обработаны)
## Функция trySetPrio
```
trySetPrio(queryHandle, pid, name, targetClass) → bool
```
1. Закрыть `queryHandle` (handle от `PROCESS_QUERY_LIMITED_INFORMATION`)
2. Открыть процесс заново с правом `PROCESS_SET_INFORMATION` (0x0200)
- Разделение прав: многие процессы дают `QUERY` но не дают `SET` в комбинации
3. Если `OpenProcess` упал:
- Добавить PID в `blocked`
- Записать `BLOCK ... open SetInformation failed`
- Вернуть `false`
4. Вызвать `SetPriorityClass(handle, targetClass)`
5. Закрыть handle (через defer)
6. Если `SetPriorityClass` упал:
- Добавить PID в `blocked`
- Записать `BLOCK ... SetPriorityClass failed`
- Вернуть `false`
7. Подождать 50мс (плавная смена приоритетов, предотвращает статтеры)
8. Вернуть `true`
## Функция setPrio (для восстановления)
```
setPrio(pid, targetClass) → error
```
1. Открыть с `PROCESS_SET_INFORMATION`
2. Вызвать `SetPriorityClass`
3. Закрыть handle
4. Вернуть ошибку или nil
## Завершение программы (deferred)
При получении сигнала SIGINT/SIGTERM:
1. Получить свежий список всех процессов
2. Построить `restoreMap` (pid → name)
3. **Восстановить `gameSaved`:** для каждого PID — проверить имя, установить оригинальный приоритет, записать `RESTORE ... -> originalPriority`
4. **Восстановить `promoted`:** для каждого PID — проверить имя, установить NORMAL, записать `RESTORE ... -> NORMAL`
5. **Восстановить `gameProcs`:** для каждого PID — проверить имя, установить NORMAL, записать `RESTORE ... -> NORMAL`
6. Записать `autoPriority stopped`
Порядок важен: `gameSaved` восстанавливается первым, потому что `promoted` мог быть удалён из `promoted` во время игрового режима, но его оригинальный приоритет сохранён в `gameSaved`.
## Классы приоритетов Windows
| Константа | Значение | Имя в логе |
|---|---|---|
| IDLE | 0x0040 | IDLE |
| BELOW_NORMAL | 0x4000 | BELOW_NORMAL |
| NORMAL | 0x0020 | NORMAL |
| ABOVE_NORMAL | 0x8000 | ABOVE_NORMAL |
| HIGH | 0x0080 | HIGH |
| REALTIME | 0x0100 | REALTIME |
### Логика isAboveNormal
Возвращает `true` для `ABOVE_NORMAL`, `HIGH`, `REALTIME`. НЕ возвращает `true` для `BELOW_NORMAL`, потому что числовое значение `BELOW_NORMAL` (0x4000) больше `NORMAL` (0x0020), хотя семантически ниже. Поэтому используется явная проверка, а не числовое сравнение.
## Логирование
Лог пишется в `%TEMP%\autopriority.log`. При запуске старый лог удаляется, создаётся новый. Каждая запись синхронизируется на диск (`Sync()`). Формат: `[DD.MM.YYYY HH:MM:SS] сообщение`.
### Префиксы логов
| Префикс | Значение |
|---|---|
| `PROMOTE` | Процесс повышен до HIGH (нормальный режим) |
| `DEMOTE` | Процесс понижен до NORMAL (нормальный режим) |
| `GAME` | Процесс изменён вgame режиме (→ HIGH или → IDLE) |
| `GAME MODE ON`| Игровой режим активирован |
| `GAME MODE OFF`| Игровой режим деактивирован, процессы восстановлены |
| `BLOCK` | OpenProcess или SetPriorityClass упал; процесс в исключениях |
| `RESTORE` | Процесс восстановлен в оригинальный приоритет |
| `[DRY-RUN]` | Было бы изменение приоритета (dry-run режим) |
## Windows API
Программа использует только `kernel32.dll` и `psapi.dll` через raw syscall. Без внешних зависимостей.
### Используемые функции
- `CreateToolhelp32Snapshot` — снимок списка процессов
- `Process32FirstW` / `Process32NextW` — обход снимка
- `OpenProcess` — открытие handle процесса
- `GetProcessMemoryInfo` — получение WorkingSetSize (RSS)
- `GetPriorityClass` — чтение текущего приоритета
- `SetPriorityClass` — установка приоритета
- `CloseHandle` — закрытие handle
- `TerminateProcess` — завершение других экземпляров
### Права доступа
- `PROCESS_QUERY_LIMITED_INFORMATION` (0x1000) — для чтения памяти и приоритета. Это право дают почти все процессы.
- `PROCESS_SET_INFORMATION` (0x0200) — для изменения приоритета. Запрашивается отдельно, только когда нужно менять приоритет.
- `PROCESS_TERMINATE` (0x0001) — для уничтожения других экземпляров.
Разделение прав критично: запрос `QUERY|SET` комбинированно приводит к Access Denied для многих пользовательских процессов (KeePassXC, HD-Player), хотя каждое право по отдельности они дают.
## Структура processEntry32
```
Size uint32
CntUsage uint32
PID uint32
DefaultHeapID uintptr // НЕ uint32 — intptr на x64
ModuleID uint32
CntThreads uint32
ParentPID uint32
PrioClass int32
Flags uint32
ExeFile [260]uint16 // UTF-16
```
`DefaultHeapID` должен быть `uintptr`, не `uint32` — иначе на x64 структура неправильно выровнена и `Process32Next` падает.
## Сценарии работы
### Сценарий 1: Обычная работа
1. Запуск программы
2. Chrome потребляет 600MB (≥ 512MB) → `PROMOTE Chrome.exe -> HIGH`, добавляется в `promoted`
3. Chrome сбрасывает память до 300MB (< 512MB) → `DEMOTE Chrome.exe -> NORMAL`, удаляется из `promoted`
4. Discord потребляет 700MB → `PROMOTE Discord.exe -> HIGH`
5. Discord сбрасывает до 200MB → `DEMOTE Discord.exe -> NORMAL`
### Сценарий 2: Понижение высокого приоритета
1. `wininit.exe` запущен с HIGH, потребляет 6MB (< 512MB порога)
2. Программа видит: RSS < mem, приоритет ABOVE_NORMAL → `DEMOTE wininit.exe -> NORMAL`
3. Это применяется ко всем процессам (включая ранее повышенные, если их RSS упал ниже порога)
### Сценарий 3: Игровой режим
1. Запуск RustClient, потребление растёт с 1GB до 8GB
2. На скане, где RSS ≥ 4GB: RustClient добавляется в `gameProcs`
3. `hasGame = true`
4. RustClient → `trySetPrio` HIGH → если античит блокирует → `BLOCK RustClient.exe` + `GAME RustClient.exe — tracked as game (priority change failed)`
5. RustClient остаётся в `gameProcs` (важно: блокировка SetPriorityClass не удаляет из gameProcs)
6. Все остальные процессы → IDLE (`GAME Chrome.exe -> IDLE`, etc.)
7. `GAME MODE ON`
8. При следующем скане RustClient всё ещё в `gameProcs`, остальные процессы уже IDLE — пропускаются
### Сценарий 4: Выход из игрового режима
1. RustClient закрыт
2. `gameProcs` очищен (PID исчез)
3. `newHasGame = false`, `gameMode = true` → переход
4. `gameMode = false`, `GAME MODE OFF`
5. Для каждого процесса в `gameSaved`: восстановить оригинальный приоритет
- `RESTORE Chrome.exe -> NORMAL` (был NORMAL до игрового режима)
- `RESTORE Discord.exe -> HIGH` (был HIGH, потому что был в `promoted`)
6. `gameSaved` очищен
7. Нормальный режим возобновляется: процессы с RSS ≥ mem повышаются, с RSS < mem и выше NORMAL — понижаются
### Сценарий 5: Заблокированный процесс становится игрой
1. RustClient стартует, потребляет 3.9GB
2. RSS ≥ 512MB (mem), не в `promoted` → попытка PROMOTE → античит блокирует → `BLOCK RustClient.exe` (добавлен в `blocked`)
3. RustClient дорастает до 8GB
4. На следующем скане: RustClient в `blocked`, но **проход 1 всё равно пытается его открыть**
5. `OpenProcess(QUERY_LIMITED)` для blocked процесса — если упадёт, пропустит. Если пройдёт — RSS измеряется
6. Если RustClient в `blocked` из-за `SetPriorityClass` (не `OpenProcess`), то `OpenProcess(QUERY_LIMITED)` пройдёт, RSS измерится, и он попадёт в `gameProcs`
7. `hasGame = true`, `GAME MODE ON`
8. В проходе 2: RustClient в `gameProcs`, но `isBlocked = true``GAME RustClient.exe — tracked as game (blocked)`, приоритет не меняется
9. Остальные процессы понижаются до IDLE
### Сценарий 6: PID reuse
1. Процесс A (PID 1234, "chrome.exe") повышен, в `promoted`
2. Процесс A умирает
3. Новый процесс B (PID 1234, "notepad.exe") запускается
4. На следующем скане: `pidMap[1234] = "notepad.exe"`, но `promoted[1234] = "chrome.exe"`
5. Имя не совпадает → `delete(promoted, 1234)`
6. Процесс B обрабатывается как новый процесс
### Сценарий 7: Системные процессы
1. `svchost.exe` (PID 932) — `OpenProcess(QUERY_LIMITED)` возвращает Access Denied
2. Добавляется в `blocked`, логируется `BLOCK`
3. На следующем скане: PID 932 всё ещё в `blocked`, `OpenProcess` снова падает, но лог НЕ пишется (процесс уже в `blocked`)
4. Когда процесс умирает: PID исчезает из `pidMap` → удаляется из `blocked`
## Сборка
```
go build -ldflags="-H windowsgui -s -w" -o autopriority.exe .
```
- `-H windowsgui` — без консольного окна
- `-s -w` — strip debug info, уменьшение размера
Требуется Go 1.26+. Только Windows (`//go:build windows`).
## Автозапуск
Поместить ярлык на `autopriority.exe` в папку `shell:startup` (Win+R → `shell:startup`).