diff --git a/agent/system.go b/agent/system.go index 1649625aa..33831580f 100644 --- a/agent/system.go +++ b/agent/system.go @@ -265,7 +265,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.Battery = systemStats.Battery - a.systemInfo.Uptime, _ = host.Uptime() + a.systemInfo.Uptime, _ = getUptime() a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.Threads = a.systemDetails.Threads diff --git a/agent/uptime_linux.go b/agent/uptime_linux.go new file mode 100644 index 000000000..a91ee8107 --- /dev/null +++ b/agent/uptime_linux.go @@ -0,0 +1,44 @@ +//go:build linux + +package agent + +import ( + "math" + "os" + "strconv" + "strings" + + "github.com/shirou/gopsutil/v4/host" +) + +// uptimeFilePath is a variable so tests can point it at a fixture. +var uptimeFilePath = "/proc/uptime" + +// getUptime returns the system uptime in seconds. +// +// This reads /proc/uptime instead of using host.Uptime(), which calls the +// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime +// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime +// rather than the container's. +// +// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so +// behavior is unchanged anywhere the file isn't available. +func getUptime() (uint64, error) { + data, err := os.ReadFile(uptimeFilePath) + if err != nil { + return host.Uptime() + } + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return host.Uptime() + } + seconds, err := strconv.ParseFloat(fields[0], 64) + if err != nil || + math.IsNaN(seconds) || + math.IsInf(seconds, 0) || + seconds < 0 || + seconds >= 1<<64 { + return host.Uptime() + } + return uint64(seconds), nil +} diff --git a/agent/uptime_linux_test.go b/agent/uptime_linux_test.go new file mode 100644 index 000000000..f6123702b --- /dev/null +++ b/agent/uptime_linux_test.go @@ -0,0 +1,101 @@ +//go:build linux + +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetUptimeFromProc(t *testing.T) { + tests := []struct { + name string + contents string + want uint64 + }{ + {"typical", "12345.67 98765.43\n", 12345}, + {"zero", "0.00 0.00\n", 0}, + {"no trailing newline", "42.99 7.00", 42}, + {"single field", "600.5", 600}, + {"large value", "266030.12 1000000.00\n", 266030}, + } + + prev := uptimeFilePath + t.Cleanup(func() { uptimeFilePath = prev }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "uptime") + if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil { + t.Fatal(err) + } + uptimeFilePath = path + + got, err := getUptime() + if err != nil { + t.Fatalf("getUptime() returned error: %v", err) + } + if got != tt.want { + t.Errorf("getUptime() = %d, want %d", got, tt.want) + } + }) + } +} + +func writeUptime(contents string) func(t *testing.T) string { + return func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "uptime") + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } + return path + } +} + +// Malformed, missing, or out-of-range input must fall back to host.Uptime() +// rather than returning a bogus value, so the agent still reports something sane. +func TestGetUptimeFallsBack(t *testing.T) { + prev := uptimeFilePath + t.Cleanup(func() { uptimeFilePath = prev }) + + for _, tt := range []struct { + name string + prepare func(t *testing.T) string + }{ + {"missing file", func(t *testing.T) string { + return filepath.Join(t.TempDir(), "does-not-exist") + }}, + {"empty file", func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "uptime") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + return path + }}, + {"unparseable", func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "uptime") + if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil { + t.Fatal(err) + } + return path + }}, + {"NaN", writeUptime("NaN 1.0\n")}, + {"positive infinity", writeUptime("+Inf 1.0\n")}, + {"negative infinity", writeUptime("-Inf 1.0\n")}, + {"negative", writeUptime("-42.5 1.0\n")}, + {"exceeds uint64 range", writeUptime("1e20 1.0\n")}, + } { + t.Run(tt.name, func(t *testing.T) { + uptimeFilePath = tt.prepare(t) + + got, err := getUptime() + if err != nil { + t.Fatalf("getUptime() returned error: %v", err) + } + if got == 0 { + t.Error("getUptime() = 0, expected fallback to host.Uptime()") + } + }) + } +} diff --git a/agent/uptime_stub.go b/agent/uptime_stub.go new file mode 100644 index 000000000..063de751c --- /dev/null +++ b/agent/uptime_stub.go @@ -0,0 +1,10 @@ +//go:build !linux + +package agent + +import "github.com/shirou/gopsutil/v4/host" + +// getUptime returns the system uptime in seconds. +func getUptime() (uint64, error) { + return host.Uptime() +}