Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:06 +08:00

175 lines
4.7 KiB
Go

//go:build linux
package proc
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/pranshuparmar/witr/pkg/model"
)
// ListProcesses returns all running processes with the columns the TUI renders
// (PID, PPID, command, user, start time, CPU%, RSS, mem%, command line). It
// reads /proc directly instead of forking `ps -axo`, computing the same
// lifetime-average CPU% that ps reports — no subprocess per refresh.
func ListProcesses() ([]model.Process, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, fmt.Errorf("read /proc: %w", err)
}
// Per-list invariants, computed once rather than per process.
ticks := ticksPerSecond()
boot := bootTime()
totalMem := float64(totalMemoryBytes())
pageSize := float64(os.Getpagesize())
processes := make([]model.Process, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
if p, ok := readProcessListEntry(pid, ticks, boot, totalMem, pageSize); ok {
processes = append(processes, p)
}
}
return processes, nil
}
// readProcessListEntry reads the TUI list columns for one PID from /proc,
// mirroring the fields the old `ps -axo` invocation produced. Returns ok=false
// when the process vanished mid-read or its stat is malformed.
func readProcessListEntry(pid, ticks int, boot time.Time, totalMem, pageSize float64) (model.Process, bool) {
stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return model.Process{}, false
}
raw := string(stat)
open := strings.Index(raw, "(")
closeParen := strings.LastIndex(raw, ")")
if open == -1 || closeParen == -1 || closeParen+2 >= len(raw) {
return model.Process{}, false
}
comm := raw[open+1 : closeParen]
fields := strings.Fields(raw[closeParen+2:])
if len(fields) < 22 {
return model.Process{}, false
}
ppid, _ := strconv.Atoi(fields[1])
utime, _ := strconv.ParseFloat(fields[11], 64)
stime, _ := strconv.ParseFloat(fields[12], 64)
startTicks, _ := strconv.ParseInt(fields[19], 10, 64)
rssPages, _ := strconv.ParseFloat(fields[21], 64)
startedAt := startTimeFromTicks(boot, startTicks, ticks)
memBytes := rssPages * pageSize
// Lifetime-average CPU%: total CPU time over wall-clock since start (what ps reports).
cpuPercent := 0.0
if wall := time.Since(startedAt).Seconds(); wall > 0 {
cpuPercent = (utime + stime) / float64(ticks) / wall * 100.0
}
memPercent := 0.0
if totalMem > 0 {
memPercent = memBytes / totalMem * 100.0
}
cmdline := ""
if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)); err == nil {
cmdline = strings.TrimSpace(strings.ReplaceAll(string(b), "\x00", " "))
}
displayName := deriveDisplayCommand(comm, cmdline)
if displayName == "" {
displayName = comm
}
if cmdline == "" {
cmdline = displayName
}
return model.Process{
PID: pid,
PPID: ppid,
Command: displayName,
Cmdline: cmdline,
User: readUser(pid),
StartedAt: startedAt,
CPUPercent: cpuPercent,
MemoryRSS: uint64(memBytes),
MemoryPercent: memPercent,
}, true
}
// ListProcessSnapshot collects a lightweight view of running processes
// for child/descendant discovery. We avoid full ReadProcess calls to keep
// this path fast and to reduce permission-sensitive reads.
func ListProcessSnapshot() ([]model.Process, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, fmt.Errorf("read /proc: %w", err)
}
processes := make([]model.Process, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
statPath := fmt.Sprintf("/proc/%d/stat", pid)
stat, err := os.ReadFile(statPath)
if err != nil {
continue
}
proc, err := parseStatSnapshot(pid, stat)
if err != nil {
continue
}
processes = append(processes, proc)
}
return processes, nil
}
func parseStatSnapshot(pid int, stat []byte) (model.Process, error) {
raw := string(stat)
open := strings.Index(raw, "(")
close := strings.LastIndex(raw, ")")
// close+2 >= len(raw) guards the raw[close+2:] slice below: a stat ending at
// the comm's ')' would otherwise panic. Matches ReadProcess's bounds check.
if open == -1 || close == -1 || close <= open || close+2 >= len(raw) {
return model.Process{}, fmt.Errorf("invalid stat format")
}
comm := raw[open+1 : close]
fields := strings.Fields(raw[close+2:])
if len(fields) < 2 {
return model.Process{}, fmt.Errorf("invalid stat format")
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
return model.Process{}, fmt.Errorf("invalid ppid")
}
return model.Process{
PID: pid,
PPID: ppid,
Command: comm,
}, nil
}