Files
kazeyukiro 05b4be66d6 fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
fix(metrics): read unit cgroup path for process memory split
memory.current was taken from the hierarchy root, so the panel never saw
system.slice/3m-ui.service figures and fell back to raw RSS. Resolve the
path from /proc/self/cgroup and split that unit working set by RSS share.
2026-09-27 02:28:53 +00:00

285 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package system
import (
"sync"
"time"
"github.com/shirou/gopsutil/v4/process"
)
// ProcessUsage is CPU + memory for a single OS process (panel or Mihomo core).
//
// CPU percentages use the same capacity as the system card (cgroup CFS quota or
// online CPUs). Memory is reported on the **cgroup working-set** basis used by
// the system card and by systemd's MemoryCurrent: when the panel and core share
// one unit, SampleProcessUsagePair splits that cgroup figure by RSS share so the
// two rows sum to the service total instead of double-counting shared pages.
type ProcessUsage struct {
PID int `json:"pid"`
CPUPercent float64 `json:"cpu_percent"`
MemoryUsed float64 `json:"memory_used"` // bytes (cgroup share, or RSS fallback)
MemoryPercent float64 `json:"memory_percent"` // 0–100 of system/cgroup total
}
// procBaseline is one process CPU counter reading, differenced against the
// previous one to get a rate.
type procBaseline struct {
// Cumulative user+system CPU seconds consumed by the PID.
total float64
at time.Time
pct float64
}
const (
// procBaselineWindow: see sampleCPU — one short wait buys a real delta so
// the first render does not read 0%.
procBaselineWindow = 120 * time.Millisecond
// procBaselineTTL: how long an unseen PID keeps its delta state before it
// is dropped, bounding the map on hosts that churn PIDs.
procBaselineTTL = 5 * time.Minute
)
var (
procCPUMu sync.Mutex
procCPUBase = map[int32]*procBaseline{}
procCPUSweep time.Time
)
// dropProcBaseline removes state for a PID that no longer exists, so a
// restarted core does not resume from the previous incarnation's counter.
func dropProcBaseline(pid int32) {
procCPUMu.Lock()
delete(procCPUBase, pid)
procCPUMu.Unlock()
}
// reapProcBaselines discards state for PIDs that have not been sampled within
// procBaselineTTL. Swept lazily (at most once a minute) because every call site
// already holds the lock.
func reapProcBaselines(now time.Time) {
if !procCPUSweep.IsZero() && now.Sub(procCPUSweep) < time.Minute {
return
}
for pid, b := range procCPUBase {
if now.Sub(b.at) >= procBaselineTTL {
delete(procCPUBase, pid)
}
}
procCPUSweep = now
}
// processCPUTotal returns cumulative CPU seconds genuinely spent on the CPU for
// the given handle.
//
// Only user+system are counted. The TimesStat also carries an Iowait field,
// but for a process gopsutil derives it from block-I/O delay ticks, which is
// waiting rather than consuming — adding it inflates the figure for I/O heavy
// workloads.
func processCPUTotal(p *process.Process) (float64, bool) {
t, err := p.Times()
if err != nil || t == nil {
return 0, false
}
return t.User + t.System, true
}
// SampleProcessUsage returns CPU/memory for pid.
//
// CPU is deliberately measured by differencing /proc/<pid>/stat ourselves.
// gopsutil's Process.CPUPercent() cannot be used because it returns
// 100 * lifetimeCPUTime / uptime: an average over the process' entire history,
// not what it is doing now. A panel that has been up for a week reports a flat
// near-constant number that lags real spikes by hours, and when the start time
// cannot be resolved (BootTime from sysinfo disagrees with /proc/stat inside
// some containers/gVisor) totalTime goes negative and the call silently yields
// 0. Validate either way.
func SampleProcessUsage(pid int) ProcessUsage {
out := sampleProcessUsageRaw(pid)
// Single-process path: attribute the whole cgroup working set to this PID
// so MemoryUsed stays ≤ system Memory.Used (same denominator as the system card).
applyCgroupMemoryShares([]*ProcessUsage{&out})
return out
}
// SampleProcessUsagePair samples panel and core together and splits the unit's
// cgroup working-set memory by RSS share. Shared library pages are no longer
// double-counted; panel.MemoryUsed + core.MemoryUsed matches systemd MemoryCurrent
// (and the system card's Used when both live in the same cgroup).
func SampleProcessUsagePair(panelPID, corePID int) (panel, core ProcessUsage) {
panel = sampleProcessUsageRaw(panelPID)
core = sampleProcessUsageRaw(corePID)
applyCgroupMemoryShares([]*ProcessUsage{&panel, &core})
return panel, core
}
// sampleProcessUsageRaw fills CPU and raw RSS; memory fields are adjusted by
// applyCgroupMemoryShares.
func sampleProcessUsageRaw(pid int) ProcessUsage {
out := ProcessUsage{PID: pid}
if pid <= 0 {
return out
}
p, err := process.NewProcess(int32(pid))
if err != nil {
return out
}
if mi, err := p.MemoryInfo(); err == nil && mi != nil {
out.MemoryUsed = float64(mi.RSS)
}
out.CPUPercent = sampleProcessCPU(p)
return out
}
// applyCgroupMemoryShares rewrites MemoryUsed/MemoryPercent so the values use
// the same cgroup working set as sampleMemory(). Shares are proportional to
// each process' RSS (only a relative weight — the absolute bytes come from the
// cgroup). Processes with no RSS or outside a readable cgroup keep RSS-based
// figures against memoryTotalForPercent().
func applyCgroupMemoryShares(procs []*ProcessUsage) {
if len(procs) == 0 {
return
}
total := memoryTotalForPercent()
cg := readServiceCgroupMemory()
hostTotal := total
info, ok := memoryFromCgroup(cg, hostTotal)
if !ok || info.Used <= 0 {
for _, u := range procs {
if u == nil || u.PID <= 0 {
continue
}
if total > 0 && u.MemoryUsed > 0 {
u.MemoryPercent = clampPercent(u.MemoryUsed / total * 100)
}
}
return
}
if info.Total > 0 {
total = info.Total
}
var rssSum float64
for _, u := range procs {
if u != nil && u.PID > 0 && u.MemoryUsed > 0 {
rssSum += u.MemoryUsed
}
}
if rssSum <= 0 {
// No RSS weights: give the whole working set to the first live PID.
for _, u := range procs {
if u != nil && u.PID > 0 {
u.MemoryUsed = info.Used
if total > 0 {
u.MemoryPercent = clampPercent(u.MemoryUsed / total * 100)
}
return
}
}
return
}
var assigned float64
var last *ProcessUsage
for _, u := range procs {
if u == nil || u.PID <= 0 {
continue
}
share := info.Used * (u.MemoryUsed / rssSum)
u.MemoryUsed = share
assigned += share
last = u
if total > 0 {
u.MemoryPercent = clampPercent(u.MemoryUsed / total * 100)
}
}
// Absorb floating error on the last process so the sum matches the cgroup.
if last != nil {
last.MemoryUsed += info.Used - assigned
if last.MemoryUsed < 0 {
last.MemoryUsed = 0
}
if total > 0 {
last.MemoryPercent = clampPercent(last.MemoryUsed / total * 100)
}
}
}
// memoryTotalForPercent returns the memory total the dashboard displays for
// system memory, so per-process percentages are expressed against the same
// denominator. GetSystemStats is TTL-cached, so this costs nothing extra on a
// dashboard poll that already samples system memory.
func memoryTotalForPercent() float64 {
if stats := GetSystemStats(); stats != nil {
return stats.Memory.Total
}
return 0
}
// sampleProcessCPU returns pid's CPU use since the previous sample, as a
// percentage of the available CPU capacity.
func sampleProcessCPU(p *process.Process) float64 {
pid := p.Pid
capacity := cpuCapacity()
if capacity <= 0 {
return 0
}
now := time.Now()
total, ok := processCPUTotal(p)
if !ok {
// The PID is gone (or no longer readable). Drop its baseline: if that
// PID ever comes back it belongs to a different process.
dropProcBaseline(pid)
return 0
}
procCPUMu.Lock()
defer procCPUMu.Unlock()
reapProcBaselines(now)
prev := procCPUBase[pid]
cur := &procBaseline{total: total, at: now}
procCPUBase[pid] = cur
if prev != nil {
cur.pct = prev.pct
}
if prev == nil {
// No baseline yet: sample twice over a short window so even the first
// render carries a measured value.
time.Sleep(procBaselineWindow)
second := time.Now()
total2, ok := processCPUTotal(p)
if !ok {
dropProcBaselineLocked(pid)
return 0
}
cur.total, cur.at = total2, second
prev = &procBaseline{total: total, at: now}
}
elapsed := cur.at.Sub(prev.at).Seconds()
delta := cur.total - prev.total
if elapsed <= 0 {
return clampPercent(cur.pct)
}
if delta < 0 {
// The PID was recycled: these counters belong to a different process
// now, so there is no valid delta. Report the last real reading rather
// than something fabricated; the baseline was already reset above, so
// the next call differences against this PID's actual start.
return clampPercent(cur.pct)
}
// A zero delta is a legitimate "this process was idle". Only negative
// deltas are impossible; echoing the previous value instead of reporting 0
// would leave the card stuck at whatever it last peaked at, because a
// tickless kernel accrues nothing for a fully sleeping process.
cur.pct = delta / elapsed / capacity * 100
return clampPercent(cur.pct)
}
func dropProcBaselineLocked(pid int32) {
delete(procCPUBase, pid)
}