diff --git a/internal/memory/collector.go b/internal/memory/collector.go index d595971..d9ad239 100644 --- a/internal/memory/collector.go +++ b/internal/memory/collector.go @@ -14,6 +14,7 @@ import ( type VirtualMemory struct { TotalBytes uint64 AvailableBytes uint64 + FreeBytes uint64 UsedBytes uint64 UsedPercent float64 BuffersBytes uint64 @@ -51,6 +52,7 @@ func (gopsutilCollector) Collect() (VirtualMemory, SwapMemory, error) { return VirtualMemory{ TotalBytes: vm.Total, AvailableBytes: vm.Available, + FreeBytes: vm.Free, UsedBytes: vm.Used, UsedPercent: vm.UsedPercent, BuffersBytes: vm.Buffers, diff --git a/internal/memory/module.go b/internal/memory/module.go index 1d76155..f4de833 100644 --- a/internal/memory/module.go +++ b/internal/memory/module.go @@ -46,24 +46,28 @@ func captureProfile(c Collector) ProfileInfo { return ProfileInfo{TotalBytes: vm.TotalBytes} } - // Name returns "memory". -func (m *Module) Name() string { return "memory" } - // Enabled reports that the memory module is always active. -func (m *Module) Enabled() bool { return true } - // DisabledReason returns ""; the memory module is never disabled. -func (m *Module) DisabledReason() string { return "" } - // Profile returns the static metadata captured at startup. -func (m *Module) Profile() any { return m.profile } - // Shutdown is a no-op; memory does not hold external resources. +// Name returns "memory". +func (m *Module) Name() string { return "memory" } + +// Enabled reports that the memory module is always active. +func (m *Module) Enabled() bool { return true } + +// DisabledReason returns ""; the memory module is never disabled. +func (m *Module) DisabledReason() string { return "" } + +// Profile returns the static metadata captured at startup. +func (m *Module) Profile() any { return m.profile } + +// Shutdown is a no-op; memory does not hold external resources. func (m *Module) Shutdown(_ context.Context) error { return nil } - // Start launches the sampling goroutine and returns immediately. +// Start launches the sampling goroutine and returns immediately. func (m *Module) Start(ctx context.Context) error { go metrics.RunLoop(ctx, m.base.Interval, m.collectOnce) return nil } - // Latest returns the most recent sample or nil. +// Latest returns the most recent sample or nil. func (m *Module) Latest() any { if m.base == nil { return nil @@ -71,7 +75,7 @@ func (m *Module) Latest() any { return m.base.Latest() } - // History returns samples within the trailing duration d, oldest first. +// History returns samples within the trailing duration d, oldest first. func (m *Module) History(d time.Duration) []any { if m.base == nil { return nil @@ -120,7 +124,7 @@ func (m *Module) Peak(d time.Duration) any { return peak } - // LastSampleAge returns the time since the most recent sample. +// LastSampleAge returns the time since the most recent sample. func (m *Module) LastSampleAge() time.Duration { if m.base == nil { return time.Duration(1<<63 - 1) @@ -128,7 +132,7 @@ func (m *Module) LastSampleAge() time.Duration { return m.base.LastSampleAge() } - // RegisterRoutes adds /metrics/memory and /metrics/memory/history to mux. +// RegisterRoutes adds /metrics/memory and /metrics/memory/history to mux. func (m *Module) RegisterRoutes(mux *http.ServeMux) { metrics.RegisterRoutes(mux, m.base, "memory", m.base.Latest, m.History, nil) } @@ -139,15 +143,11 @@ func (m *Module) collectOnce() { m.base.Publish(Sample{Timestamp: nowUTC(), Error: err.Error()}) return } - // htop's "used" excludes reclaimable buffers and page cache. Match - // that here so the webview's stacked bar segments add up to ~100% - // of total without overlap. Saturating subtraction would be wrong - // on non-Linux platforms where the kernel fields are zero; the - // conditional guards against that. - var usedNoCache uint64 - if vm.BuffersBytes+vm.CachedBytes < vm.UsedBytes { - usedNoCache = vm.UsedBytes - vm.BuffersBytes - vm.CachedBytes - } + // Compute the htop-style "used" segment from raw meminfo fields when + // available. Do not subtract Buffers/Cached from gopsutil Used; on Linux + // Used is already derived from availability, so that double subtraction + // collapses the webview value to 0 on hosts with large page cache. + usedNoCache := usedWithoutReclaimable(vm) m.base.Publish(Sample{ Timestamp: nowUTC(), TotalBytes: vm.TotalBytes, @@ -162,3 +162,19 @@ func (m *Module) collectOnce() { SharedBytes: vm.SharedBytes, }) } + +func usedWithoutReclaimable(vm VirtualMemory) uint64 { + if vm.TotalBytes > 0 && vm.FreeBytes > 0 && vm.FreeBytes <= vm.TotalBytes { + used := vm.TotalBytes - vm.FreeBytes + if vm.BuffersBytes <= used { + used -= vm.BuffersBytes + if vm.CachedBytes <= used { + return used - vm.CachedBytes + } + } + } + if vm.TotalBytes > 0 && vm.AvailableBytes > 0 && vm.AvailableBytes <= vm.TotalBytes { + return vm.TotalBytes - vm.AvailableBytes + } + return vm.UsedBytes +} diff --git a/internal/memory/module_test.go b/internal/memory/module_test.go index 1555731..9bbdb53 100644 --- a/internal/memory/module_test.go +++ b/internal/memory/module_test.go @@ -51,6 +51,9 @@ func TestMemory_PublishesSample(t *testing.T) { if p.TotalBytes != 1000 || p.UsedBytes != 400 || p.UsedPercent != 40 { t.Errorf("unexpected sample: %+v", *p) } + if p.UsedNoCacheBytes != 400 { + t.Errorf("used_no_cache = %d, want 400", p.UsedNoCacheBytes) + } if p.SwapTotalBytes != 200 || p.SwapUsedBytes != 50 { t.Errorf("swap fields wrong: %+v", *p) } @@ -70,6 +73,25 @@ func TestMemory_PublishesSample(t *testing.T) { } } +func TestMemory_UsedNoCacheDoesNotSubtractCacheFromUsed(t *testing.T) { + fc := &fakeCollector{ + vm: VirtualMemory{ + TotalBytes: 1000, + AvailableBytes: 700, + FreeBytes: 100, + UsedBytes: 300, + UsedPercent: 30, + BuffersBytes: 100, + CachedBytes: 500, + }, + } + m := NewWithCollector(fc, time.Second, 4, newTestLogger()) + p := m.Latest().(*Sample) + if p.UsedNoCacheBytes != 300 { + t.Errorf("used_no_cache = %d, want 300", p.UsedNoCacheBytes) + } +} + func TestMemory_PropagatesError(t *testing.T) { fc := &fakeCollector{err: errors.New("boom")} m := NewWithCollector(fc, time.Second, 4, newTestLogger()) @@ -87,14 +109,14 @@ func TestMemory_PropagatesError(t *testing.T) { func TestMemory_Peak(t *testing.T) { // Total stays constant at 1000 across the window. The "real used" - // (Total - Free - Buffers - Cached) goes 250 → 450 → 150. The peak - // should report 450 and a recomputed UsedPercent of 45. + // (Total - Free - Buffers - Cached) goes 400 → 600 → 300. The peak + // should report 600 and a recomputed UsedPercent of 60. fc := &fakeCollector{ sw: SwapMemory{TotalBytes: 200, UsedBytes: 50}, seq: []VirtualMemory{ - {TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, UsedPercent: 40, BuffersBytes: 100, CachedBytes: 50}, - {TotalBytes: 1000, UsedBytes: 600, AvailableBytes: 400, UsedPercent: 60, BuffersBytes: 100, CachedBytes: 50}, - {TotalBytes: 1000, UsedBytes: 300, AvailableBytes: 700, UsedPercent: 30, BuffersBytes: 100, CachedBytes: 50}, + {TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, FreeBytes: 500, UsedPercent: 40, BuffersBytes: 50, CachedBytes: 50}, + {TotalBytes: 1000, UsedBytes: 600, AvailableBytes: 400, FreeBytes: 300, UsedPercent: 60, BuffersBytes: 50, CachedBytes: 50}, + {TotalBytes: 1000, UsedBytes: 300, AvailableBytes: 700, FreeBytes: 600, UsedPercent: 30, BuffersBytes: 50, CachedBytes: 50}, }, } m := NewWithCollector(fc, 30*time.Millisecond, 8, newTestLogger()) @@ -108,13 +130,13 @@ func TestMemory_Peak(t *testing.T) { t.Fatal("expected non-nil peak") } p := peak.(*Sample) - if p.UsedNoCacheBytes != 450 { - t.Errorf("peak used_no_cache = %d, want 450", p.UsedNoCacheBytes) + if p.UsedNoCacheBytes != 600 { + t.Errorf("peak used_no_cache = %d, want 600", p.UsedNoCacheBytes) } // UsedPercent is recomputed from the peaked used_no_cache, not taken // from any one sample. - if p.UsedPercent != 45 { - t.Errorf("peak used_percent = %f, want 45", p.UsedPercent) + if p.UsedPercent != 60 { + t.Errorf("peak used_percent = %f, want 60", p.UsedPercent) } // Non-stress fields come from the latest in-window sample (300 used). if p.UsedBytes != 300 { diff --git a/internal/memory/types.go b/internal/memory/types.go index 0a9b49f..6a83e73 100644 --- a/internal/memory/types.go +++ b/internal/memory/types.go @@ -8,13 +8,11 @@ import "time" // underlying gopsutil calls failed; in that case all numeric fields are // zero values. // -// UsedBytes is gopsutil's "Total - Free", which INCLUDES buffers and -// page cache. Most `free -h` style tools report this number. htop's -// "used" bar shows a different concept — non-reclaimable memory only -// (Total - Free - Buffers - Cached); that value is exposed as -// UsedNoCacheBytes so the webview can render an htop-faithful stacked -// bar where the used / buffers / cached segments add up to ~100% of -// total without overlap. +// UsedBytes is gopsutil's human-consumable used memory. On Linux that is +// Total - Available, not Total - Free. htop's "used" bar shows a narrower +// concept: Total - Free - Buffers - Cached. That value is exposed as +// UsedNoCacheBytes so the webview can render the used / buffers / cached +// segments without double-counting reclaimable cache. // // The kernel breakdown fields (BuffersBytes / CachedBytes / SharedBytes) // are populated on Linux from /proc/meminfo via gopsutil; they are zero on