36b3af2e3d
PR Check / Code Quality: Format (push) Failing after 1s
PR Check / Code Quality: Lint (darwin) (push) Failing after 0s
PR Check / Code Quality: Lint (freebsd) (push) Failing after 1s
PR Check / Code Quality: Lint (windows) (push) Failing after 1s
PR Check / Code Quality: Lint (linux) (push) Failing after 1s
PR Check / Security: Vulnerability Scan (push) Failing after 0s
Update Documentation / update-docs (push) Failing after 2s
PR Check / Code Quality: Vendor (push) Failing after 1s
PR Check / Code Quality: Coverage (push) Failing after 0s
PR Check / Tests: Unit (macos-latest) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04) (push) Has been cancelled
PR Check / Tests: Unit (ubuntu-24.04-arm) (push) Has been cancelled
PR Check / Tests: Unit (windows-latest) (push) Has been cancelled
194 lines
4.6 KiB
Go
194 lines
4.6 KiB
Go
//go:build linux
|
|
|
|
package proc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/godbus/dbus/v5"
|
|
"github.com/pranshuparmar/witr/pkg/model"
|
|
)
|
|
|
|
// GetResourceContext returns resource usage context for a process
|
|
func GetResourceContext(pid int) *model.ResourceContext {
|
|
ctx := &model.ResourceContext{}
|
|
ctx.PreventsSleep = checkPreventsSleep(pid)
|
|
ctx.ThermalState = getThermalState()
|
|
ctx.AppNapped = getAppNapped(pid)
|
|
|
|
// Compute CPU% once and derive both the usage figure and the energy-impact
|
|
// label from it, instead of recomputing CPU via a second (slower) `top`.
|
|
if cpu, err := GetCPUPercent(pid, true); err == nil {
|
|
ctx.CPUUsage = cpu
|
|
ctx.EnergyImpact = energyImpactLabel(cpu)
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// thermal zone info from /sys/class/thermal
|
|
func getThermalState() string {
|
|
|
|
path := "/sys/class/thermal/thermal_zone0/temp"
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
return ""
|
|
}
|
|
readText, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
tempstr := strings.TrimSpace(string(readText))
|
|
temp, err := strconv.Atoi(tempstr)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
tempC := temp / 1000
|
|
switch {
|
|
case tempC > 90:
|
|
return fmt.Sprintf("Critical thermal pressure %d", tempC)
|
|
case tempC > 70:
|
|
return fmt.Sprintf("High thermal pressure %d", tempC)
|
|
case tempC > 60:
|
|
return fmt.Sprintf("Warm thermal state %d", tempC)
|
|
default:
|
|
return fmt.Sprintf("Normal thermal state %d", tempC)
|
|
}
|
|
}
|
|
|
|
// checkPreventsSleep checks if a process has sleep prevention assertions
|
|
func checkPreventsSleep(pid int) bool {
|
|
conn, err := dbus.SystemBus()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
// org.freedesktop.login1.Manager.ListInhibitors returns a(ssssuu):
|
|
// (what, who, why, mode, uid, pid). Reading it over D-Bus avoids forking
|
|
// `systemd-inhibit --list` on every resource lookup. SystemBus() returns a
|
|
// shared connection, so we don't close it here.
|
|
var inhibitors []struct {
|
|
What, Who, Why, Mode string
|
|
UID, PID uint32
|
|
}
|
|
obj := conn.Object("org.freedesktop.login1", dbus.ObjectPath("/org/freedesktop/login1"))
|
|
if err := obj.CallWithContext(ctx, "org.freedesktop.login1.Manager.ListInhibitors", 0).Store(&inhibitors); err != nil {
|
|
return false
|
|
}
|
|
for _, in := range inhibitors {
|
|
if int(in.PID) != pid {
|
|
continue
|
|
}
|
|
what := strings.ToLower(in.What)
|
|
if strings.Contains(what, "sleep") || strings.Contains(what, "idle") || strings.Contains(what, "shutdown") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// detect if process is in a stopped/suspended state
|
|
func getAppNapped(pid int) bool {
|
|
statFile := fmt.Sprintf("/proc/%d/stat", pid)
|
|
data, err := os.ReadFile(statFile)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
dataStr := string(data)
|
|
lastParenIndex := strings.LastIndex(dataStr, ")")
|
|
if lastParenIndex == -1 || lastParenIndex+2 >= len(dataStr) {
|
|
return false
|
|
}
|
|
|
|
rest := dataStr[lastParenIndex+2:]
|
|
fields := strings.Fields(rest)
|
|
if len(fields) < 1 {
|
|
return false
|
|
}
|
|
|
|
state := fields[0]
|
|
return state == "T" || state == "t"
|
|
}
|
|
|
|
// energyImpactLabel maps a CPU-usage percentage to a coarse energy-impact band.
|
|
func energyImpactLabel(cpu float64) string {
|
|
switch {
|
|
case cpu > 50:
|
|
return "Very High"
|
|
case cpu > 25:
|
|
return "High"
|
|
case cpu > 10:
|
|
return "Medium"
|
|
case cpu > 2:
|
|
return "Low"
|
|
case cpu > 0:
|
|
return "Very Low"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func GetCPUPercent(pid int, usePs ...bool) (float64, error) {
|
|
var cpu float64
|
|
|
|
shouldUsePs := len(usePs) > 0 && usePs[0]
|
|
|
|
if shouldUsePs {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "pcpu=").Output()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
cpuStr := strings.TrimSpace(string(out))
|
|
if cpuStr == "" {
|
|
return 0, fmt.Errorf("empty ps output")
|
|
}
|
|
|
|
cpu, err = strconv.ParseFloat(cpuStr, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
} else {
|
|
// Use top (default)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
out, err := exec.CommandContext(ctx, "top", "-b", "-n", "1", "-p", strconv.Itoa(pid)).Output()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
lines := strings.Split(string(out), "\n")
|
|
found := false
|
|
|
|
for _, line := range lines {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 9 && fields[0] == strconv.Itoa(pid) {
|
|
cpuStr := strings.TrimSuffix(fields[8], "%")
|
|
cpu, err = strconv.ParseFloat(cpuStr, 64)
|
|
if err == nil {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return 0, fmt.Errorf("process not found in top output")
|
|
}
|
|
}
|
|
|
|
return cpu, nil
|
|
}
|