diff --git a/agent/app/service/process.go b/agent/app/service/process.go index dc33c20b628b..27d3671427d9 100644 --- a/agent/app/service/process.go +++ b/agent/app/service/process.go @@ -12,6 +12,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/utils/common" + agentPsutil "github.com/1Panel-dev/1Panel/agent/utils/psutil" "github.com/1Panel-dev/1Panel/agent/utils/websocket" "github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/process" @@ -128,7 +129,7 @@ func (ps *ProcessService) GetProcessInfoByPID(pid int32) (*websocket.PsProcessDa } } - if createTime, err := p.CreateTime(); err == nil { + if createTime, err := agentPsutil.NewProcessCreateTimeResolver().CreateTime(p); err == nil { data.StartTime = time.Unix(createTime/1000, 0).Format("2006-01-02 15:04:05") } diff --git a/agent/go.mod b/agent/go.mod index c99757f1957e..250465f7d692 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -49,6 +49,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/subosito/gotenv v1.6.0 github.com/tencentyun/cos-go-sdk-v5 v0.7.75 + github.com/tklauser/go-sysconf v0.3.16 github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 github.com/upyun/go-sdk v2.1.0+incompatible go.mongodb.org/mongo-driver/v2 v2.8.0 @@ -193,7 +194,6 @@ require ( github.com/therootcompany/xz v1.0.1 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect diff --git a/agent/utils/psutil/process.go b/agent/utils/psutil/process.go new file mode 100644 index 000000000000..cabf5f92318e --- /dev/null +++ b/agent/utils/psutil/process.go @@ -0,0 +1,122 @@ +package psutil + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/shirou/gopsutil/v4/process" + "github.com/tklauser/go-sysconf" +) + +const defaultClockTicks = 100 +const maxProcessCreateTimeSkew = time.Minute + +type ProcessCreateTimeResolver struct { + procRoot string + bootTime int64 + clockTicks uint64 +} + +func NewProcessCreateTimeResolver() *ProcessCreateTimeResolver { + procRoot := os.Getenv("HOST_PROC") + if procRoot == "" { + procRoot = "/proc" + } + + resolver := &ProcessCreateTimeResolver{ + procRoot: procRoot, + clockTicks: defaultClockTicks, + } + if clockTicks, err := sysconf.Sysconf(sysconf.SC_CLK_TCK); err == nil && clockTicks > 0 { + resolver.clockTicks = uint64(clockTicks) + } + if bootTime, err := readBootTime(filepath.Join(procRoot, "stat")); err == nil { + resolver.bootTime = bootTime + } + return resolver +} + +// CreateTime returns the process start time in milliseconds since Unix epoch. +// +// On some LXC systems, gopsutil combines a container-relative /proc/uptime with +// the host-relative starttime from /proc//stat, which can place the start +// time in the future. In that case, reading btime and starttime from /proc keeps +// both values on the same clock base, matching the calculation used by ps. +func (r *ProcessCreateTimeResolver) CreateTime(proc *process.Process) (int64, error) { + now := time.Now() + createTime, createTimeErr := proc.CreateTime() + if createTimeErr == nil && isValidProcessCreateTime(createTime, now) { + return createTime, nil + } + + if r.bootTime > 0 && r.clockTicks > 0 { + statPath := filepath.Join(r.procRoot, strconv.Itoa(int(proc.Pid)), "stat") + if content, err := os.ReadFile(statPath); err == nil { + if startTicks, err := parseProcessStartTicks(string(content)); err == nil { + startMillis := startTicks * 1000 / r.clockTicks + fallbackTime := r.bootTime*1000 + int64(startMillis) + if isValidProcessCreateTime(fallbackTime, now) { + return fallbackTime, nil + } + } + } + } + if createTimeErr != nil { + return 0, fmt.Errorf("resolve process create time: %w", createTimeErr) + } + return 0, fmt.Errorf("invalid process create time: %d", createTime) +} + +func isValidProcessCreateTime(createTime int64, now time.Time) bool { + return createTime > 0 && createTime <= now.Add(maxProcessCreateTimeSkew).UnixMilli() +} + +func readBootTime(path string) (int64, error) { + file, err := os.Open(path) + if err != nil { + return 0, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 || fields[0] != "btime" { + continue + } + bootTime, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return 0, fmt.Errorf("parse btime: %w", err) + } + if bootTime <= 0 { + return 0, errors.New("invalid btime") + } + return bootTime, nil + } + if err := scanner.Err(); err != nil { + return 0, err + } + return 0, errors.New("btime not found") +} + +func parseProcessStartTicks(stat string) (uint64, error) { + // The second field (comm) is wrapped in parentheses and may contain spaces + // or parentheses, so split only after its closing parenthesis. The remaining + // fields begin at field 3 (state), making starttime (field 22) index 19. + commEnd := strings.LastIndex(stat, ")") + if commEnd == -1 || commEnd+1 >= len(stat) { + return 0, errors.New("invalid process stat") + } + fields := strings.Fields(stat[commEnd+1:]) + const startTimeIndex = 19 + if len(fields) <= startTimeIndex { + return 0, errors.New("process stat has too few fields") + } + return strconv.ParseUint(fields[startTimeIndex], 10, 64) +} diff --git a/agent/utils/websocket/process_data.go b/agent/utils/websocket/process_data.go index 392326075fb2..9daf8a6ec381 100644 --- a/agent/utils/websocket/process_data.go +++ b/agent/utils/websocket/process_data.go @@ -13,6 +13,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/files" + agentPsutil "github.com/1Panel-dev/1Panel/agent/utils/psutil" "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/net" "github.com/shirou/gopsutil/v4/process" @@ -159,7 +160,7 @@ func getDownloadProcess(progress DownloadProgress) (res []byte, err error) { return } -func handleProcessData(proc *process.Process, processConfig *PsProcessConfig, pidConnections map[int32][]net.ConnectionStat) *PsProcessData { +func handleProcessData(proc *process.Process, processConfig *PsProcessConfig, pidConnections map[int32][]net.ConnectionStat, createTimeResolver *agentPsutil.ProcessCreateTimeResolver) *PsProcessData { if processConfig.Pid > 0 && processConfig.Pid != proc.Pid { return nil } @@ -185,10 +186,10 @@ func handleProcessData(proc *process.Process, processConfig *PsProcessConfig, pi if len(statusArray) > 0 { procData.Status = strings.Join(statusArray, ",") } - createTime, procErr := proc.CreateTime() + createTime, procErr := createTimeResolver.CreateTime(proc) if procErr == nil { t := time.Unix(createTime/1000, 0) - procData.StartTime = t.Format("2006-1-2 15:04:05") + procData.StartTime = t.Format("2006-01-02 15:04:05") } procData.NumThreads, _ = proc.NumThreads() procData.CpuValue, _ = proc.CPUPercent() @@ -231,9 +232,10 @@ func getProcessData(processConfig PsProcessConfig) (res []byte, err error) { } result := make([]PsProcessData, 0, len(processes)) + createTimeResolver := agentPsutil.NewProcessCreateTimeResolver() for _, proc := range processes { - procData := handleProcessData(proc, &processConfig, pidConnections) + procData := handleProcessData(proc, &processConfig, pidConnections, createTimeResolver) if procData != nil { result = append(result, *procData) }