Files
ITWorx-Pulse-Public/internal/hostcollect/process.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

353 lines
10 KiB
Go

package hostcollect
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/itworx/pulse/internal/process"
)
// maxProcessCPUPercent matches the bound internal/process enforces. A busy multi-core
// task legitimately exceeds 100%.
const maxProcessCPUPercent = 10000
// processKey identifies a task across scans. The PID alone is not enough: PIDs are
// reused, and charging a new process with the CPU time of the dead one it replaced
// would show a fresh task at an absurd utilisation. The kernel's start time makes the
// identity stable.
type processKey struct {
pid int
startTime uint64
}
type processCPUSample struct {
at time.Time
ticks uint64
}
// processStat is the subset of /proc/<pid>/stat the inventory needs.
type processStat struct {
pid int
comm string
state string
ticks uint64
startTime uint64
rssPages uint64
}
func (s processStat) key() processKey { return processKey{pid: s.pid, startTime: s.startTime} }
// parseProcessStat reads /proc/<pid>/stat.
//
// The second field is the executable name in parentheses and it is neither escaped nor
// quoted: a process can legitimately be called ") (" or "my app (2)". Splitting the
// line on whitespace therefore corrupts every field after it. The parser instead cuts
// at the LAST closing parenthesis, which is the only reliable delimiter.
func parseProcessStat(data []byte, pid int) (processStat, error) {
text := string(data)
open := strings.IndexByte(text, '(')
closing := strings.LastIndexByte(text, ')')
if open < 0 || closing < open {
return processStat{}, errors.New("hostcollect: process stat has no comm field")
}
comm := text[open+1 : closing]
fields := strings.Fields(text[closing+1:])
// Field 3 (state) becomes index 0 here; the inventory needs up to field 24 (rss).
const (
stateIndex = 0
utimeIndex = 11
stimeIndex = 12
startTimeIndex = 19
rssIndex = 21
)
if len(fields) <= rssIndex {
return processStat{}, errors.New("hostcollect: process stat is truncated")
}
utime, utimeOK := parseUint(fields[utimeIndex])
stime, stimeOK := parseUint(fields[stimeIndex])
startTime, startOK := parseUint(fields[startTimeIndex])
if !utimeOK || !stimeOK || !startOK {
return processStat{}, errors.New("hostcollect: process stat has malformed counters")
}
// rss is signed in the kernel's own printf but never negative in practice; a
// negative or malformed value degrades to zero rather than failing the process.
rss, _ := parseUint(fields[rssIndex])
return processStat{
pid: pid,
comm: sanitizeName(comm),
state: processState(fields[stateIndex]),
ticks: utime + stime,
startTime: startTime,
rssPages: rss,
}, nil
}
// processState expands the kernel's single-letter state so the UI does not have to.
func processState(value string) string {
if value == "" {
return "unknown"
}
switch value[0] {
case 'R':
return "running"
case 'S':
return "sleeping"
case 'D':
return "uninterruptible"
case 'Z':
return "zombie"
case 'T':
return "stopped"
case 't':
return "tracing-stop"
case 'X', 'x':
return "dead"
case 'I':
return "idle"
case 'K':
return "wakekill"
case 'W':
return "waking"
case 'P':
return "parked"
default:
return "unknown"
}
}
// Processes reads the process inventory.
//
// A process that exits between readdir and open is the normal case, not an error: the
// scan skips it and keeps going. Only a failure that invalidates the whole scan (no
// uptime, unreadable procfs root) returns an error, because a half-empty inventory
// published as complete would be worse than no inventory at all.
func (c *Collector) Processes(ctx context.Context) (process.RawSnapshot, error) {
if c == nil {
return process.RawSnapshot{}, errors.New("hostcollect: collector is nil")
}
if err := ctx.Err(); err != nil {
return process.RawSnapshot{}, err
}
now := c.options.Now().UTC()
uptimeSeconds, err := c.uptimeSeconds()
if err != nil {
return process.RawSnapshot{}, err
}
entries, err := os.ReadDir(c.options.ProcRoot)
if err != nil {
return process.RawSnapshot{}, fmt.Errorf("hostcollect: list processes: %w", err)
}
stats := make([]processStat, 0, len(entries))
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return process.RawSnapshot{}, err
}
pid, ok := processID(entry.Name())
if !ok {
continue
}
data, readErr := readLimited(c.procPath(entry.Name(), "stat"), maxProcessFileBytes)
if readErr != nil {
continue
}
stat, parseErr := parseProcessStat(data, pid)
if parseErr != nil {
continue
}
stats = append(stats, stat)
}
percentages := c.cpuPercentages(now, stats)
rows := c.rank(stats, percentages)
processes := make([]process.RawProcess, 0, len(rows))
for _, stat := range rows {
if err := ctx.Err(); err != nil {
return process.RawSnapshot{}, err
}
row, ok := c.describe(stat, uptimeSeconds, percentages[stat.key()])
if !ok {
continue
}
processes = append(processes, row)
}
sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID })
return process.RawSnapshot{
Source: process.Source{
ID: "process",
Type: "agent",
CapabilityVersion: process.ContractVersion,
ObservedAt: now,
},
Processes: processes,
ObservedAt: now,
}, nil
}
// cpuPercentages turns accumulated CPU ticks into a utilisation percentage relative to
// the previous scan and replaces the retained state with the current one. The map is
// rebuilt from the live process list every scan, so it cannot grow without bound as
// processes come and go.
func (c *Collector) cpuPercentages(now time.Time, stats []processStat) map[processKey]float64 {
percentages := make(map[processKey]float64, len(stats))
current := make(map[processKey]processCPUSample, len(stats))
c.mu.Lock()
previous := c.processes
for _, stat := range stats {
key := stat.key()
current[key] = processCPUSample{at: now, ticks: stat.ticks}
sample, seen := previous[key]
if !seen {
continue
}
elapsed := now.Sub(sample.at).Seconds()
if elapsed <= 0 || stat.ticks < sample.ticks {
continue
}
percent := float64(stat.ticks-sample.ticks) / c.options.ClockTicks / elapsed * 100
if percent < 0 {
percent = 0
}
if percent > maxProcessCPUPercent {
percent = maxProcessCPUPercent
}
percentages[key] = percent
}
c.processes = current
c.mu.Unlock()
return percentages
}
// rank keeps the busiest processes when the inventory exceeds MaxRows. Truncating by
// cost rather than by PID order keeps the rows an operator actually needs.
func (c *Collector) rank(stats []processStat, percentages map[processKey]float64) []processStat {
ordered := append([]processStat(nil), stats...)
sort.SliceStable(ordered, func(i, j int) bool {
left, right := percentages[ordered[i].key()], percentages[ordered[j].key()]
if left != right {
return left > right
}
if ordered[i].rssPages != ordered[j].rssPages {
return ordered[i].rssPages > ordered[j].rssPages
}
return ordered[i].pid < ordered[j].pid
})
if len(ordered) > c.options.ProcessLimits.MaxRows {
ordered = ordered[:c.options.ProcessLimits.MaxRows]
}
return ordered
}
// describe fills in the fields that need a second read, for the retained rows only.
//
// Only the program name is taken from the command line, never the arguments: argv
// routinely carries tokens, passwords and connection strings, and the snapshot is
// stored and rendered. The read itself is bounded, so an enormous argv costs one page,
// not a copy of the whole command line.
func (c *Collector) describe(stat processStat, uptimeSeconds, cpuPercent float64) (process.RawProcess, bool) {
directory := strconv.Itoa(stat.pid)
name := stat.comm
if data, err := readLimited(c.procPath(directory, "cmdline"), maxCmdlineBytes); err == nil {
if argv0 := commandName(data); argv0 != "" {
name = argv0
}
}
if name == "" {
// Neither comm nor cmdline is usable — the task is gone or unreadable.
return process.RawProcess{}, false
}
memoryBytes := stat.rssPages * uint64(c.options.PageSize) //nolint:gosec // PageSize is validated positive
if rss, ok := c.residentBytes(directory); ok {
memoryBytes = rss
}
runtimeSeconds := uptimeSeconds - float64(stat.startTime)/c.options.ClockTicks
if runtimeSeconds < 0 || runtimeSeconds > maxUptimeSeconds {
runtimeSeconds = 0
}
return process.RawProcess{
PID: stat.pid,
Name: truncate(name, maxNameBytes),
State: stat.state,
RuntimeSeconds: runtimeSeconds,
CPUPercent: cpuPercent,
MemoryBytes: memoryBytes,
}, true
}
// residentBytes prefers VmRSS from /proc/<pid>/status, which the kernel already
// reports in kB and keeps consistent, over the page count in stat. Kernel threads have
// no VmRSS at all, which is why the stat value remains the fallback.
func (c *Collector) residentBytes(directory string) (uint64, bool) {
data, err := readLimited(c.procPath(directory, "status"), maxProcessFileBytes)
if err != nil {
return 0, false
}
for _, line := range lines(data) {
key, rest, found := strings.Cut(line, ":")
if !found || key != "VmRSS" {
continue
}
fields := strings.Fields(rest)
if len(fields) == 0 {
return 0, false
}
value, ok := parseUint(fields[0])
if !ok {
return 0, false
}
if len(fields) > 1 && strings.EqualFold(fields[1], "kB") {
const kib = 1024
if value > (1<<64-1)/kib {
return 0, false
}
value *= kib
}
return value, true
}
return 0, false
}
// commandName extracts the program name from a NUL separated command line.
func commandName(data []byte) string {
text := string(data)
if index := strings.IndexByte(text, 0); index >= 0 {
text = text[:index]
}
text = strings.TrimSpace(text)
if text == "" {
return ""
}
return sanitizeName(filepath.Base(text))
}
// sanitizeName drops control characters so a hostile process name cannot inject
// terminal escapes or NUL bytes into logs and stored snapshots.
func sanitizeName(value string) string {
cleaned := strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, value)
return truncate(strings.TrimSpace(cleaned), maxNameBytes)
}
func processID(name string) (int, bool) {
pid, err := strconv.Atoi(name)
if err != nil || pid < 1 {
return 0, false
}
return pid, true
}