diff --git a/README.md b/README.md index 6b73032..ece9f9d 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,39 @@ # autoPriority -Monitors process memory usage on Windows and automatically adjusts CPU priority: promotes memory-heavy processes to HIGH and demotes everything else to NORMAL. +Monitors process memory usage on Windows and automatically adjusts CPU priority: promotes memory-heavy processes to HIGH and demotes everything else to NORMAL. Optionally enters **Game Mode** when a process exceeds a higher threshold, boosting it to HIGH and setting all other processes to IDLE. **Windows only.** ## How it works +### Normal mode + Every scan interval the program iterates over all running processes: -| RSS vs threshold | Current priority | Action | +| RSS vs `-mem` | Current priority | Action | |---|---|---| | ≥ threshold | anything except HIGH | → HIGH (logged as `PROMOTE`) | | ≥ threshold | already HIGH | skip | | < threshold | ABOVE_NORMAL, HIGH, or REALTIME | → NORMAL (logged as `DEMOTE`) | | < threshold | NORMAL, BELOW_NORMAL, or IDLE | skip | -If `SetPriorityClass` fails for a process (e.g., anti-cheat protection), the process is added to an in-memory exclusion list and never touched again (logged as `BLOCK`). When the process exits, it is automatically removed from the list. +### Game mode (`-game-mem`) -On shutdown, all promoted processes are restored to NORMAL. +When any process reaches or exceeds the `-game-mem` threshold: + +1. That process → **HIGH** (logged as `GAME`) +2. All other processes → **IDLE** +3. Log: `GAME MODE ON` + +When all such processes close: + +1. All processes that were demoted during game mode are **restored to their original priority** (logged as `RESTORE`) +2. `GAME MODE OFF` logged +3. Normal mode resumes — priorities recalculated by `-mem` rules + +If `SetPriorityClass` or `OpenProcess` fails for a process (e.g., anti-cheat protection, system processes), the process is added to an in-memory exclusion list and never touched again (logged as `BLOCK`). When the process exits, it is automatically removed from the list. + +On shutdown, all processes are restored: game-mode demoted processes to their saved original priority, promoted and game processes to NORMAL. ## Build @@ -39,15 +55,22 @@ autopriority [flags] | Flag | Default | Description | |-------------|---------------|----------------------------------| -| `-mem` | 536870912 | Memory threshold in bytes (512 MB) | +| `-mem` | 512M | Memory threshold (e.g. 512M, 1G, 2048M) | +| `-game-mem` | 4G | Game threshold (e.g. 3G, 4G). Must be > `-mem`. 0 = disabled | | `-interval` | 1 minute | Scan interval (min 10s) | | `-dry-run` | false | Log only, don't change priority | Examples: ``` -# Run with 1 GB memory threshold, scanning every 30 seconds -autopriority -mem=1073741824 -interval=30s +# Run with 1 GB threshold, scanning every 30 seconds +autopriority -mem=1G -interval=30s + +# Run with defaults (512M threshold, 4G game threshold) +autopriority + +# Game mode: normal threshold 512M, game threshold 4G +autopriority -mem=512M -game-mem=4G # Dry run — log decisions without changing anything autopriority -dry-run @@ -61,10 +84,13 @@ Log entries: | Prefix | Meaning | |---|---| -| `PROMOTE` | Priority raised to HIGH | -| `DEMOTE` | Priority lowered to NORMAL | -| `BLOCK` | SetPriorityClass failed; process added to exclusion list | -| `RESTORE` | Promoted process restored to NORMAL on shutdown | +| `PROMOTE` | Priority raised to HIGH (normal mode) | +| `DEMOTE` | Priority lowered to NORMAL (normal mode) | +| `GAME` | Process set to HIGH or IDLE (game mode) | +| `GAME MODE ON` | Game mode activated | +| `GAME MODE OFF` | Game mode deactivated — processes restored | +| `BLOCK` | OpenProcess or SetPriorityClass failed; process added to exclusion list | +| `RESTORE` | Process restored to original/saved priority (game mode exit or shutdown) | | `SKIP RESTORE` | PID reused by a different process; restore skipped | | `[DRY-RUN]` | Would change priority (dry-run mode) | diff --git a/main.go b/main.go index 1276b4f..c306a88 100644 --- a/main.go +++ b/main.go @@ -3,12 +3,13 @@ package main import ( - "errors" "flag" "fmt" "os" "os/signal" "path/filepath" + "strconv" + "strings" "syscall" "time" "unsafe" @@ -16,19 +17,17 @@ import ( const ( CreateToolhelp32SnapshotProcess = 0x00000002 + ProcessTerminate = 0x00000001 ProcessSetInformation = 0x00000200 ProcessQueryInformation = 0x00000400 + ProcessQueryLimitedInformation = 0x00001000 PriorityClassIdle = 0x00000040 PriorityClassNormal = 0x00000020 PriorityClassHigh = 0x00000080 PriorityClassAboveNormal = 0x00008000 PriorityClassRealtime = 0x00000100 PriorityClassBelowNormal = 0x00004000 - MessageBoxIconWarning = 0x00000030 - ErrorAlreadyExists = 183 ErrorNoMoreFiles = 18 - ErrorAccessDenied = 5 - ErrorInvalidParameter = 87 ) type processEntry32 struct { @@ -75,8 +74,7 @@ var ( procGetPriority = k32.NewProc("GetPriorityClass") procOpenProcess = k32.NewProc("OpenProcess") procCloseHandle = k32.NewProc("CloseHandle") - procCreateMutex = k32.NewProc("CreateMutexW") - procMessageBox = k32.NewProc("MessageBoxW") + procTerminate = k32.NewProc("TerminateProcess") ) func closeH(h syscall.Handle) { @@ -104,11 +102,6 @@ func setPrio(pid uint32, cls uint32) error { return nil } -func ignoreOpenError(err error) bool { - return errors.Is(err, syscall.Errno(ErrorAccessDenied)) || - errors.Is(err, syscall.Errno(ErrorInvalidParameter)) -} - func curPrioWithHandle(h syscall.Handle) (uint32, error) { r, _, e := procGetPriority.Call(uintptr(h)) if r == 0 { @@ -126,6 +119,49 @@ func utf16Trim(p []uint16) string { return syscall.UTF16ToString(p) } +func parseMemSize(s string) (uint64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty value") + } + var suffix byte + switch s[len(s)-1] { + case 'k', 'K': + suffix = 'K' + s = s[:len(s)-1] + case 'm', 'M': + suffix = 'M' + s = s[:len(s)-1] + case 'g', 'G': + suffix = 'G' + s = s[:len(s)-1] + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, err + } + switch suffix { + case 'K': + return n * 1024, nil + case 'M': + return n * 1024 * 1024, nil + case 'G': + return n * 1024 * 1024 * 1024, nil + default: + return n, 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 allProcs() ([]processInfo, error) { snap, _, e := procCreateSnap.Call(CreateToolhelp32SnapshotProcess, 0) if snap == 0 { @@ -182,67 +218,29 @@ func prioName(c uint32) string { } } -func procName(pid uint32) (string, error) { - snap, _, e := procCreateSnap.Call(CreateToolhelp32SnapshotProcess, 0) - if snap == 0 { - return "", 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 "", fmt.Errorf("Process32First failed: %w", e) - } - for { - if pe.PID == pid { - return utf16Trim(pe.ExeFile[:]), 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 +func killOtherInstances() { + myPID := os.Getpid() + procs, err := allProcs() + if err != nil { + return + } + myName := filepath.Base(os.Args[0]) + for _, p := range procs { + if p.PID != uint32(myPID) && filepath.Base(p.Name) == myName { + h, err := openProc(p.PID, ProcessTerminate) + if err == nil { + procTerminate.Call(uintptr(h), 0) + closeH(h) } - return "", fmt.Errorf("Process32Next failed: %w", e) } } - return "", nil -} - -func isAlive(pid uint32, expectedName string) bool { - h, err := openProc(pid, ProcessQueryInformation) - if err != nil || h == 0 { - return false - } - closeH(h) - name, err := procName(pid) - if err != nil || name == "" { - return false - } - return name == expectedName -} - -func ensureSingleInstance() syscall.Handle { - h, _, err := procCreateMutex.Call(0, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Global\\autopriority_v1")))) - if h == 0 { - fmt.Fprintf(os.Stderr, "autoPriority: failed to create mutex: %v\n", err) - os.Exit(1) - } - if err == syscall.Errno(ErrorAlreadyExists) { - procMessageBox.Call(0, - uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("autoPriority is already running."))), - uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("autoPriority"))), - MessageBoxIconWarning, - ) - os.Exit(1) - } - return syscall.Handle(h) } func main() { - mem := flag.Uint64("mem", 512*1024*1024, "memory threshold in bytes") + memStr := flag.String("mem", "512M", "memory threshold (e.g. 512M, 1G, 2048M)") + gameMemStr := flag.String("game-mem", "4G", "game memory threshold (e.g. 3G, 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() @@ -251,6 +249,25 @@ func main() { *interval = 10 * time.Second } + mem, err := parseMemSize(*memStr) + if err != nil { + 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) + } + + killOtherInstances() + time.Sleep(100 * time.Millisecond) + var logFile *os.File logPath := filepath.Join(os.TempDir(), "autopriority.log") os.Remove(logPath) @@ -274,10 +291,11 @@ func main() { logFile.Sync() } - logf("autoPriority started (mem=%d bytes, interval=%s, dry-run=%v)", *mem, *interval, *dryRun) - - mutex := ensureSingleInstance() - defer closeH(mutex) + if gameMem > 0 { + logf("autoPriority started (mem=%s, game-mem=%s, interval=%s, dry-run=%v)", formatMemSize(mem), formatMemSize(gameMem), *interval, *dryRun) + } else { + logf("autoPriority started (mem=%s, interval=%s, dry-run=%v)", formatMemSize(mem), *interval, *dryRun) + } if err := setPrio(uint32(os.Getpid()), PriorityClassIdle); err != nil { logf("warning: could not set own priority to IDLE: %v", err) @@ -287,11 +305,44 @@ func main() { promoted := make(map[uint32]string) blocked := make(map[uint32]string) + gameProcs := make(map[uint32]string) + gameMode := false + + type savedPrio struct { + name string + prio uint32 + } + gameSaved := make(map[uint32]savedPrio) defer func() { + restoreProcs, _ := allProcs() + restoreMap := make(map[uint32]string, len(restoreProcs)) + for _, p := range restoreProcs { + restoreMap[p.PID] = p.Name + } + for pid, sv := range gameSaved { + if cur, ok := restoreMap[pid]; !ok || cur != sv.name { + continue + } + if err := setPrio(pid, sv.prio); err != nil { + logf("RESTORE %s (PID %d) -> %s error: %v", sv.name, pid, prioName(sv.prio), err) + } else { + logf("RESTORE %s (PID %d) -> %s", sv.name, pid, prioName(sv.prio)) + } + } for pid, name := range promoted { - cur, err := procName(pid) - if err != nil || cur != name { + if cur, ok := restoreMap[pid]; !ok || cur != name { + logf("SKIP RESTORE PID %d: process name mismatch (expected %s, got %s)", pid, name, cur) + continue + } + if err := setPrio(pid, PriorityClassNormal); err != nil { + logf("RESTORE %s (PID %d) -> NORMAL error: %v", name, pid, err) + } else { + logf("RESTORE %s (PID %d) -> NORMAL", name, pid) + } + } + for pid, name := range gameProcs { + if cur, ok := restoreMap[pid]; !ok || cur != name { logf("SKIP RESTORE PID %d: process name mismatch (expected %s, got %s)", pid, name, cur) continue } @@ -311,6 +362,25 @@ func main() { stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + trySetPrio := func(qh syscall.Handle, pid uint32, name string, cls uint32) bool { + closeH(qh) + sh, err := openProc(pid, ProcessSetInformation) + if err != nil { + blocked[pid] = name + logf("BLOCK %s (PID %d): open SetInformation failed: %v (added to exclusion list)", name, pid, err) + return false + } + defer closeH(sh) + sr, _, se := procSetPriority.Call(uintptr(sh), uintptr(cls)) + if sr == 0 { + blocked[pid] = name + logf("BLOCK %s (PID %d): SetPriorityClass failed: %v (added to exclusion list)", name, pid, se) + return false + } + time.Sleep(50 * time.Millisecond) + return true + } + scan := func() { procs, err := allProcs() if err != nil { @@ -318,34 +388,47 @@ func main() { return } + pidMap := make(map[uint32]string, len(procs)) + for _, p := range procs { + pidMap[p.PID] = p.Name + } + for pid, name := range promoted { - if !isAlive(pid, name) { + if cur, ok := pidMap[pid]; !ok || cur != name { delete(promoted, pid) } } - for pid, name := range blocked { - if !isAlive(pid, name) { + for pid := range blocked { + if _, ok := pidMap[pid]; !ok { delete(blocked, pid) } } + for pid, name := range gameProcs { + if cur, ok := pidMap[pid]; !ok || cur != name { + delete(gameProcs, pid) + } + } + + type procRSS struct { + info processInfo + rss uint64 + } + var procList []procRSS + for _, p := range procs { if p.PID == myPID || p.PID == 0 { continue } - if _, ok := promoted[p.PID]; ok { - continue - } if _, ok := blocked[p.PID]; ok { continue } - h, err := openProc(p.PID, ProcessQueryInformation|ProcessSetInformation) + h, err := openProc(p.PID, ProcessQueryLimitedInformation) if err != nil { - if !ignoreOpenError(err) { - logf("openProc(%s, PID %d) error: %v", p.Name, p.PID, err) - } + blocked[p.PID] = p.Name + logf("BLOCK %s (PID %d): %v (added to exclusion list)", p.Name, p.PID, err) continue } @@ -357,29 +440,116 @@ func main() { logf("GetProcessMemoryInfo(%d) error: %v", p.PID, e) continue } + closeH(h) rssBytes := uint64(m.WorkingSetSize) - if rssBytes < *mem { - origPrio, prioErr := curPrioWithHandle(h) - if prioErr == nil && isAboveNormal(origPrio) { + + procList = append(procList, procRSS{p, rssBytes}) + + if gameMem > 0 && rssBytes >= gameMem { + if _, ok := gameProcs[p.PID]; !ok { + gameProcs[p.PID] = p.Name + } + } + } + + hasGame := gameMem > 0 && len(gameProcs) > 0 + + for _, pr := range procList { + p := pr.info + rssBytes := pr.rss + + if _, ok := gameProcs[p.PID]; ok { + if hasGame { + h, err := openProc(p.PID, ProcessQueryLimitedInformation) + if err != nil { + continue + } + origPrio, prioErr := curPrioWithHandle(h) + if prioErr != nil { + closeH(h) + continue + } + if origPrio == PriorityClassHigh { + closeH(h) + continue + } if *dryRun { - logf("[DRY-RUN] %s (PID %d) RSS=%dMB, priority=%s — would set NORMAL", - p.Name, p.PID, rssBytes/1024/1024, prioName(origPrio)) + closeH(h) + logf("[DRY-RUN] %s (PID %d) RSS=%s — would set HIGH (game)", + p.Name, p.PID, formatMemSize(rssBytes)) } else { - sr, _, se := procSetPriority.Call(uintptr(h), uintptr(PriorityClassNormal)) - if sr != 0 { - logf("DEMOTE %s (PID %d) RSS=%dMB, %s -> NORMAL", - p.Name, p.PID, rssBytes/1024/1024, prioName(origPrio)) + if trySetPrio(h, p.PID, p.Name, PriorityClassHigh) { + logf("GAME %s (PID %d) RSS=%s -> HIGH", + p.Name, p.PID, formatMemSize(rssBytes)) } else { - blocked[p.PID] = p.Name -logf("BLOCK %s (PID %d): %v (added to exclusion list)", p.Name, p.PID, se) + logf("GAME %s (PID %d) RSS=%s — tracked as game (priority change failed)", + p.Name, p.PID, formatMemSize(rssBytes)) } } } - closeH(h) continue } - rssMB := rssBytes / 1024 / 1024 + if hasGame { + h, err := openProc(p.PID, ProcessQueryLimitedInformation) + if err != nil { + continue + } + origPrio, prioErr := curPrioWithHandle(h) + if prioErr != nil { + closeH(h) + continue + } + if origPrio == PriorityClassIdle { + closeH(h) + continue + } + origStr := prioName(origPrio) + if *dryRun { + closeH(h) + logf("[DRY-RUN] %s (PID %d) RSS=%s, priority=%s — would set IDLE (game)", + p.Name, p.PID, formatMemSize(rssBytes), origStr) + } else { + if trySetPrio(h, p.PID, p.Name, PriorityClassIdle) { + if _, saved := gameSaved[p.PID]; !saved { + gameSaved[p.PID] = savedPrio{name: p.Name, prio: origPrio} + } + delete(promoted, p.PID) + logf("GAME %s (PID %d) RSS=%s, %s -> IDLE", + p.Name, p.PID, formatMemSize(rssBytes), origStr) + } + } + continue + } + + if _, ok := promoted[p.PID]; ok { + continue + } + + h, err := openProc(p.PID, ProcessQueryLimitedInformation) + if err != nil { + continue + } + + if rssBytes < mem { + origPrio, prioErr := curPrioWithHandle(h) + if prioErr == nil && isAboveNormal(origPrio) { + if *dryRun { + closeH(h) + logf("[DRY-RUN] %s (PID %d) RSS=%s, priority=%s — would set NORMAL", + p.Name, p.PID, formatMemSize(rssBytes), prioName(origPrio)) + } else { + if trySetPrio(h, p.PID, p.Name, PriorityClassNormal) { + logf("DEMOTE %s (PID %d) RSS=%s, %s -> NORMAL", + p.Name, p.PID, formatMemSize(rssBytes), prioName(origPrio)) + } + } + } else { + closeH(h) + } + continue + } + origPrio, prioErr := curPrioWithHandle(h) if prioErr != nil { closeH(h) @@ -393,22 +563,45 @@ logf("BLOCK %s (PID %d): %v (added to exclusion list)", p.Name, p.PID, se) if *dryRun { closeH(h) - logf("[DRY-RUN] %s (PID %d) RSS=%dMB, priority=%s — would set HIGH", - p.Name, p.PID, rssMB, origStr) + logf("[DRY-RUN] %s (PID %d) RSS=%s, priority=%s — would set HIGH", + p.Name, p.PID, formatMemSize(rssBytes), origStr) continue } - sr, _, se := procSetPriority.Call(uintptr(h), uintptr(PriorityClassHigh)) - closeH(h) - if sr == 0 { - blocked[p.PID] = p.Name - logf("BLOCK %s (PID %d): %v (added to exclusion list)", p.Name, p.PID, se) - continue + if trySetPrio(h, p.PID, p.Name, PriorityClassHigh) { + promoted[p.PID] = p.Name + logf("PROMOTE %s (PID %d) RSS=%s, %s -> HIGH", + p.Name, p.PID, formatMemSize(rssBytes), origStr) } + } - promoted[p.PID] = p.Name - logf("PROMOTE %s (PID %d) RSS=%dMB, %s -> HIGH", - p.Name, p.PID, rssMB, origStr) + if gameMem > 0 { + newHasGame := len(gameProcs) > 0 + if newHasGame && !gameMode { + gameMode = true + logf("GAME MODE ON") + } + if !newHasGame && gameMode { + gameMode = false + logf("GAME MODE OFF") + for pid, sv := range gameSaved { + cur, ok := pidMap[pid] + if !ok || cur != sv.name { + delete(gameSaved, pid) + continue + } + if *dryRun { + logf("[DRY-RUN] RESTORE %s (PID %d) -> %s", sv.name, pid, prioName(sv.prio)) + } else { + if err := setPrio(pid, sv.prio); err != nil { + logf("RESTORE %s (PID %d) -> %s error: %v", sv.name, pid, prioName(sv.prio), err) + } else { + logf("RESTORE %s (PID %d) -> %s", sv.name, pid, prioName(sv.prio)) + } + } + delete(gameSaved, pid) + } + } } }