Skip to content

Commit cbac0b1

Browse files
committed
new procinfo package
1 parent 101d09b commit cbac0b1

4 files changed

Lines changed: 396 additions & 0 deletions

File tree

pkg/util/procinfo/procinfo.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package procinfo
5+
6+
import "errors"
7+
8+
// ErrNotFound is returned by GetProcInfo when the requested pid does not exist.
9+
var ErrNotFound = errors.New("procinfo: process not found")
10+
11+
// LinuxStatStatus maps the single-character state from /proc/[pid]/stat to a human-readable name.
12+
var LinuxStatStatus = map[string]string{
13+
"R": "running",
14+
"S": "sleeping",
15+
"D": "disk-wait",
16+
"Z": "zombie",
17+
"T": "stopped",
18+
"t": "tracing-stop",
19+
"W": "paging",
20+
"X": "dead",
21+
"x": "dead",
22+
"K": "wakekill",
23+
"P": "parked",
24+
"I": "idle",
25+
}
26+
27+
// ProcInfo holds per-process information read from the OS.
28+
// CpuUser and CpuSys are cumulative CPU seconds since process start;
29+
// callers should diff two samples over a known interval to derive a rate.
30+
type ProcInfo struct {
31+
Pid int32
32+
Ppid int32
33+
Command string
34+
Status string
35+
CpuUser float64 // cumulative user CPU seconds
36+
CpuSys float64 // cumulative system CPU seconds
37+
VmRSS uint64 // resident set size in bytes
38+
Uid uint32
39+
NumThreads int32
40+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package procinfo
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"syscall"
10+
11+
goproc "github.com/shirou/gopsutil/v4/process"
12+
"golang.org/x/sys/unix"
13+
)
14+
15+
16+
// darwinStatStatus maps P_stat from ExternProc to a human-readable name.
17+
// Values from sys/proc.h: SIDL=1, SRUN=2, SSLEEP=3, SSTOP=4, SZOMB=5, SDEAD=6.
18+
var darwinStatStatus = map[int8]string{
19+
1: "idle",
20+
2: "running",
21+
3: "sleeping",
22+
4: "stopped",
23+
5: "zombie",
24+
6: "dead",
25+
}
26+
27+
func MakeGlobalSnapshot() (any, error) {
28+
return nil, nil
29+
}
30+
31+
// GetProcInfo reads process information for the given pid.
32+
// Core fields come from kern.proc.pid sysctl; VmRSS and NumThreads are
33+
// fetched via gopsutil (which uses proc_pidinfo internally).
34+
func GetProcInfo(ctx context.Context, _ any, pid int32) (*ProcInfo, error) {
35+
k, err := unix.SysctlKinfoProc("kern.proc.pid", int(pid))
36+
if err != nil {
37+
if err == syscall.ESRCH {
38+
return nil, ErrNotFound
39+
}
40+
return nil, fmt.Errorf("procinfo: SysctlKinfoProc pid %d: %w", pid, err)
41+
}
42+
43+
status, ok := darwinStatStatus[k.Proc.P_stat]
44+
if !ok {
45+
status = "unknown"
46+
}
47+
48+
// P_uticks and P_sticks are cumulative user/system time in microseconds.
49+
cpuUser := float64(k.Proc.P_uticks) / 1e6
50+
cpuSys := float64(k.Proc.P_sticks) / 1e6
51+
52+
info := &ProcInfo{
53+
Pid: int32(k.Proc.P_pid),
54+
Ppid: k.Eproc.Ppid,
55+
Command: unix.ByteSliceToString(k.Proc.P_comm[:]),
56+
Status: status,
57+
CpuUser: cpuUser,
58+
CpuSys: cpuSys,
59+
Uid: k.Eproc.Ucred.Uid,
60+
}
61+
62+
if p, err := goproc.NewProcessWithContext(ctx, pid); err == nil {
63+
if mi, err := p.MemoryInfoWithContext(ctx); err == nil {
64+
info.VmRSS = mi.RSS
65+
}
66+
if nt, err := p.NumThreadsWithContext(ctx); err == nil {
67+
info.NumThreads = nt
68+
}
69+
}
70+
71+
return info, nil
72+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package procinfo
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"os"
11+
"strconv"
12+
"strings"
13+
)
14+
15+
// userHz is USER_HZ, the kernel's timer frequency used in /proc/[pid]/stat CPU fields.
16+
// On Linux this is always 100.
17+
const userHz = 100.0
18+
19+
// pageSize is cached at init since it never changes at runtime.
20+
var pageSize int64
21+
22+
func init() {
23+
pageSize = int64(os.Getpagesize())
24+
if pageSize <= 0 {
25+
pageSize = 4096
26+
}
27+
}
28+
29+
func MakeGlobalSnapshot() (any, error) {
30+
return nil, nil
31+
}
32+
33+
// GetProcInfo reads process information for the given pid from /proc.
34+
// It reads /proc/[pid]/stat for most fields and /proc/[pid]/status for the UID.
35+
func GetProcInfo(_ context.Context, _ any, pid int32) (*ProcInfo, error) {
36+
info, err := readStat(pid)
37+
if err != nil {
38+
return nil, err
39+
}
40+
if uid, err := readUid(pid); err == nil {
41+
info.Uid = uid
42+
} else if errors.Is(err, ErrNotFound) {
43+
return nil, ErrNotFound
44+
}
45+
return info, nil
46+
}
47+
48+
// readStat parses /proc/[pid]/stat.
49+
//
50+
// The comm field (field 2) is enclosed in parentheses and may contain spaces
51+
// and even parentheses itself, so we locate the last ')' to find the field
52+
// boundary rather than splitting on whitespace naively.
53+
func readStat(pid int32) (*ProcInfo, error) {
54+
path := fmt.Sprintf("/proc/%d/stat", pid)
55+
data, err := os.ReadFile(path)
56+
if err != nil {
57+
if errors.Is(err, os.ErrNotExist) {
58+
return nil, ErrNotFound
59+
}
60+
return nil, fmt.Errorf("procinfo: read %s: %w", path, err)
61+
}
62+
s := strings.TrimRight(string(data), "\n")
63+
64+
// Locate comm: everything between first '(' and last ')'.
65+
lp := strings.Index(s, "(")
66+
rp := strings.LastIndex(s, ")")
67+
if lp < 0 || rp < 0 || rp <= lp {
68+
return nil, fmt.Errorf("procinfo: malformed stat for pid %d", pid)
69+
}
70+
71+
pidStr := strings.TrimSpace(s[:lp])
72+
comm := s[lp+1 : rp]
73+
rest := strings.Fields(s[rp+1:])
74+
75+
// rest[0] = field 3 (state), rest[1] = field 4 (ppid), ...
76+
// Fields after comm are numbered starting at 3, so rest[i] = field (i+3).
77+
// We need:
78+
// rest[0] = field 3 state
79+
// rest[1] = field 4 ppid
80+
// rest[11] = field 14 utime
81+
// rest[12] = field 15 stime
82+
// rest[17] = field 20 num_threads
83+
// rest[21] = field 24 rss (pages)
84+
if len(rest) < 22 {
85+
return nil, fmt.Errorf("procinfo: too few fields in stat for pid %d", pid)
86+
}
87+
88+
parsedPid, err := strconv.ParseInt(pidStr, 10, 32)
89+
if err != nil {
90+
return nil, fmt.Errorf("procinfo: parse pid: %w", err)
91+
}
92+
93+
ppid, _ := strconv.ParseInt(rest[1], 10, 32)
94+
utime, _ := strconv.ParseUint(rest[11], 10, 64)
95+
stime, _ := strconv.ParseUint(rest[12], 10, 64)
96+
numThreads, _ := strconv.ParseInt(rest[17], 10, 32)
97+
rssPages, _ := strconv.ParseInt(rest[21], 10, 64)
98+
99+
statusChar := rest[0]
100+
status, ok := LinuxStatStatus[statusChar]
101+
if !ok {
102+
status = "unknown"
103+
}
104+
105+
info := &ProcInfo{
106+
Pid: int32(parsedPid),
107+
Ppid: int32(ppid),
108+
Command: comm,
109+
Status: status,
110+
CpuUser: float64(utime) / userHz,
111+
CpuSys: float64(stime) / userHz,
112+
VmRSS: uint64(rssPages * pageSize),
113+
NumThreads: int32(numThreads),
114+
}
115+
return info, nil
116+
}
117+
118+
// readUid reads the real UID from /proc/[pid]/status.
119+
// The Uid line looks like: Uid: 1000 1000 1000 1000
120+
func readUid(pid int32) (uint32, error) {
121+
path := fmt.Sprintf("/proc/%d/status", pid)
122+
data, err := os.ReadFile(path)
123+
if err != nil {
124+
if errors.Is(err, os.ErrNotExist) {
125+
return 0, ErrNotFound
126+
}
127+
return 0, fmt.Errorf("procinfo: read %s: %w", path, err)
128+
}
129+
for _, line := range strings.Split(string(data), "\n") {
130+
if !strings.HasPrefix(line, "Uid:") {
131+
continue
132+
}
133+
fields := strings.Fields(line)
134+
if len(fields) < 2 {
135+
break
136+
}
137+
uid, err := strconv.ParseUint(fields[1], 10, 32)
138+
if err != nil {
139+
break
140+
}
141+
return uint32(uid), nil
142+
}
143+
return 0, fmt.Errorf("procinfo: Uid line not found in %s", path)
144+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package procinfo
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"syscall"
11+
"unsafe"
12+
13+
"golang.org/x/sys/windows"
14+
)
15+
16+
var modpsapi = syscall.NewLazyDLL("psapi.dll")
17+
var procGetProcessMemoryInfo = modpsapi.NewProc("GetProcessMemoryInfo")
18+
19+
// processMemoryCounters mirrors PROCESS_MEMORY_COUNTERS from psapi.h.
20+
type processMemoryCounters struct {
21+
CB uint32
22+
PageFaultCount uint32
23+
PeakWorkingSetSize uintptr
24+
WorkingSetSize uintptr
25+
QuotaPeakPagedPoolUsage uintptr
26+
QuotaPagedPoolUsage uintptr
27+
QuotaPeakNonPagedPoolUsage uintptr
28+
QuotaNonPagedPoolUsage uintptr
29+
PagefileUsage uintptr
30+
PeakPagefileUsage uintptr
31+
}
32+
33+
// snapInfo holds the data collected in a single pass of CreateToolhelp32Snapshot.
34+
type snapInfo struct {
35+
ppid uint32
36+
numThreads uint32
37+
exeName string
38+
}
39+
40+
// windowsSnapshot is the concrete type returned by MakeGlobalSnapshot on Windows.
41+
type windowsSnapshot struct {
42+
procs map[int32]*snapInfo
43+
}
44+
45+
// MakeGlobalSnapshot enumerates all processes once via CreateToolhelp32Snapshot.
46+
func MakeGlobalSnapshot() (any, error) {
47+
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
48+
if err != nil {
49+
return nil, fmt.Errorf("procinfo: CreateToolhelp32Snapshot: %w", err)
50+
}
51+
defer windows.CloseHandle(snap)
52+
53+
procs := make(map[int32]*snapInfo)
54+
55+
var entry windows.ProcessEntry32
56+
entry.Size = uint32(unsafe.Sizeof(entry))
57+
58+
if err := windows.Process32First(snap, &entry); err != nil {
59+
return nil, fmt.Errorf("procinfo: Process32First: %w", err)
60+
}
61+
for {
62+
pid := int32(entry.ProcessID)
63+
procs[pid] = &snapInfo{
64+
ppid: entry.ParentProcessID,
65+
numThreads: entry.Threads,
66+
exeName: windows.UTF16ToString(entry.ExeFile[:]),
67+
}
68+
if err := windows.Process32Next(snap, &entry); err != nil {
69+
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
70+
break
71+
}
72+
return nil, fmt.Errorf("procinfo: Process32Next: %w", err)
73+
}
74+
}
75+
76+
return &windowsSnapshot{procs: procs}, nil
77+
}
78+
79+
// GetProcInfo returns a ProcInfo for the given pid.
80+
// snap must be a non-nil value returned by MakeGlobalSnapshot.
81+
// Returns nil, nil if the pid is not present in the snapshot.
82+
func GetProcInfo(_ context.Context, snap any, pid int32) (*ProcInfo, error) {
83+
if snap == nil {
84+
return nil, fmt.Errorf("procinfo: GetProcInfo requires a snapshot on windows")
85+
}
86+
ws, ok := snap.(*windowsSnapshot)
87+
if !ok {
88+
return nil, fmt.Errorf("procinfo: invalid snapshot type")
89+
}
90+
si, found := ws.procs[pid]
91+
if !found {
92+
return nil, ErrNotFound
93+
}
94+
95+
info := &ProcInfo{
96+
Pid: pid,
97+
Ppid: int32(si.ppid),
98+
NumThreads: int32(si.numThreads),
99+
Command: si.exeName,
100+
}
101+
102+
handle, err := windows.OpenProcess(
103+
windows.PROCESS_QUERY_LIMITED_INFORMATION,
104+
false,
105+
uint32(pid),
106+
)
107+
if err != nil {
108+
// ERROR_INVALID_PARAMETER means the pid no longer exists.
109+
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
110+
return nil, ErrNotFound
111+
}
112+
return info, nil
113+
}
114+
defer windows.CloseHandle(handle)
115+
116+
var creation, exit, kernel, user windows.Filetime
117+
if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err == nil {
118+
info.CpuUser = filetimeToSeconds(user)
119+
info.CpuSys = filetimeToSeconds(kernel)
120+
}
121+
122+
var mc processMemoryCounters
123+
mc.CB = uint32(unsafe.Sizeof(mc))
124+
r, _, _ := procGetProcessMemoryInfo.Call(
125+
uintptr(handle),
126+
uintptr(unsafe.Pointer(&mc)),
127+
uintptr(mc.CB),
128+
)
129+
if r != 0 {
130+
info.VmRSS = uint64(mc.WorkingSetSize)
131+
}
132+
133+
return info, nil
134+
}
135+
136+
// filetimeToSeconds converts a FILETIME (100-ns intervals) to cumulative seconds.
137+
func filetimeToSeconds(ft windows.Filetime) float64 {
138+
ns100 := (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
139+
return float64(ns100) / 1e7
140+
}

0 commit comments

Comments
 (0)