diff --git a/agent/system.go b/agent/system.go index a79819b7..eeb55cf1 100644 --- a/agent/system.go +++ b/agent/system.go @@ -169,21 +169,11 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { // memory if v, err := mem.VirtualMemory(); err == nil { + used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop") // swap systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) - systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) - // cache + buffers value for default mem calculation - // note: gopsutil automatically adds SReclaimable to v.Cached - cacheBuff := v.Cached + v.Buffers - v.Shared - if cacheBuff <= 0 { - cacheBuff = max(v.Total-v.Free-v.Used, 0) - } - // htop memory calculation overrides (likely outdated as of mid 2025) - if a.memCalc == "htop" { - // cacheBuff = v.Cached + v.Buffers - v.Shared - v.Used = v.Total - (v.Free + cacheBuff) - v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 - } + systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed) + v.Used = used // if a.memCalc == "legacy" { // v.Used = v.Total - v.Free - v.Buffers - v.Cached // cacheBuff = v.Total - v.Free - v.Used @@ -193,10 +183,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { if a.zfs { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { v.Used = v.Used - arcSize - v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) } } + if v.Total > 0 { + v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 + } else { + v.UsedPercent = 0 + } systemStats.Mem = utils.BytesToGigabytes(v.Total) systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemUsed = utils.BytesToGigabytes(v.Used) @@ -263,6 +257,38 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { return systemStats } +// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may +// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero. +func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) { + used = v.Used + if used > v.Total { + used = saturatingSub(v.Total, v.Available) + } + + // gopsutil automatically adds SReclaimable to Cached. + cacheBuff = min(v.Cached, v.Total) + cacheBuff += min(v.Buffers, v.Total-cacheBuff) + cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total)) + if v.Cached == 0 && v.Buffers == 0 { + cacheBuff = saturatingSub(v.Total, v.Free, used) + } + if htop { + used = saturatingSub(v.Total, v.Free, cacheBuff) + } + return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached) +} + +// saturatingSub subtracts each value, returning zero on underflow. +func saturatingSub(value uint64, subtrahends ...uint64) uint64 { + for _, subtrahend := range subtrahends { + if subtrahend > value { + return 0 + } + value -= subtrahend + } + return value +} + // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems func getOsPrettyName() (string, error) { file, err := os.Open("/etc/os-release") diff --git a/agent/system_test.go b/agent/system_test.go index ff74696d..98c21bac 100644 --- a/agent/system_test.go +++ b/agent/system_test.go @@ -5,6 +5,7 @@ import ( "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" + "github.com/shirou/gopsutil/v4/mem" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -33,6 +34,59 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) { assert.Nil(t, secondResponse.Details) } +func TestCalculateHostMemoryUsage(t *testing.T) { + tests := []struct { + name string + memory mem.VirtualMemoryStat + htop bool + used, cacheBuff, swapUsed uint64 + }{ + { + name: "normal", + memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2}, + used: 60, + cacheBuff: 30, + swapUsed: 10, + }, + { + name: "inconsistent counters saturate", + memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2}, + used: 0, + cacheBuff: 0, + swapUsed: 0, + }, + { + name: "htop subtraction saturates", + memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5}, + htop: true, + used: 0, + cacheBuff: 25, + swapUsed: 15, + }, + { + name: "zero cache from shared cancellation does not fall back", + memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30}, + used: 60, + cacheBuff: 0, + }, + { + name: "absent cache counters use fallback", + memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10}, + used: 60, + cacheBuff: 30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop) + assert.Equal(t, tt.used, used) + assert.Equal(t, tt.cacheBuff, cacheBuff) + assert.Equal(t, tt.swapUsed, swapUsed) + }) + } +} + func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) { agent := &Agent{}