Honor namespaced cgroup memory limits

This commit is contained in:
pulse-triage[bot] 2026-08-29 20:51:56 +01:00
parent a966264bb1
commit 1a48d3cbd6
8 changed files with 247 additions and 18 deletions

View file

@ -109,9 +109,33 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
<MetricRow label="CPU Cores" value={props.diagnosticsData?.system?.numCPU} />
<MetricRow label="Goroutines" value={props.diagnosticsData?.system?.numGoroutine} />
<MetricRow
label="Memory"
value={`${props.diagnosticsData?.system?.memoryMB || 0} MB`}
label="Live Heap"
value={`${props.diagnosticsData?.system?.heapAllocMB ?? props.diagnosticsData?.system?.memoryMB ?? 0} MB`}
/>
<Show when={props.diagnosticsData?.system?.processRssMB !== undefined}>
<MetricRow
label="Process RSS"
value={`${props.diagnosticsData?.system?.processRssMB ?? 0} MB`}
/>
</Show>
<Show when={props.diagnosticsData?.system?.runtimeRetainedMB !== undefined}>
<MetricRow
label="Go Retained"
value={`${props.diagnosticsData?.system?.runtimeRetainedMB ?? 0} MB`}
/>
</Show>
<Show when={props.diagnosticsData?.system?.heapIdleMB !== undefined}>
<MetricRow
label="Heap Idle / Released"
value={`${props.diagnosticsData?.system?.heapIdleMB ?? 0} / ${props.diagnosticsData?.system?.heapReleasedMB ?? 0} MB`}
/>
</Show>
<Show when={(props.diagnosticsData?.system?.gcMemoryLimitMB ?? 0) > 0}>
<MetricRow
label="GC Soft Limit"
value={`${props.diagnosticsData?.system?.gcMemoryLimitMB ?? 0} MB`}
/>
</Show>
</DiagnosticCard>
<DiagnosticCard

View file

@ -9,6 +9,57 @@ describe('DiagnosticsResultsPanel', () => {
cleanup();
});
it('distinguishes live heap, retained runtime memory, and process RSS', () => {
const diagnosticsData = {
version: '6.4.1',
runtime: 'go',
uptime: 3600,
nodes: [],
pbs: [],
system: {
os: 'linux',
arch: 'amd64',
goVersion: 'go1.26.7',
numCPU: 4,
numGoroutine: 40,
memoryMB: 24,
heapAllocMB: 24,
heapIdleMB: 40,
heapReleasedMB: 16,
runtimeRetainedMB: 64,
processRssMB: 96,
gcMemoryLimitMB: 230,
},
errors: [],
} as DiagnosticsData;
render(() => (
<Router>
<Route
path="/"
component={() => (
<DiagnosticsResultsPanel
diagnosticsData={diagnosticsData}
loading={false}
onRunDiagnostics={() => {}}
/>
)}
/>
</Router>
));
expect(screen.getByText('Live Heap')).toBeInTheDocument();
expect(screen.getByText('24 MB')).toBeInTheDocument();
expect(screen.getByText('Process RSS')).toBeInTheDocument();
expect(screen.getByText('96 MB')).toBeInTheDocument();
expect(screen.getByText('Go Retained')).toBeInTheDocument();
expect(screen.getByText('64 MB')).toBeInTheDocument();
expect(screen.getByText('Heap Idle / Released')).toBeInTheDocument();
expect(screen.getByText('40 / 16 MB')).toBeInTheDocument();
expect(screen.getByText('GC Soft Limit')).toBeInTheDocument();
expect(screen.getByText('230 MB')).toBeInTheDocument();
});
it('labels Pulse Assistant diagnostics as native runtime status', () => {
const diagnosticsData = {
version: '6.0.0',

View file

@ -37,6 +37,13 @@ export interface SystemDiagnostic {
numCPU: number;
numGoroutine: number;
memoryMB: number;
processRssMB?: number;
heapAllocMB?: number;
heapInUseMB?: number;
heapIdleMB?: number;
heapReleasedMB?: number;
runtimeRetainedMB?: number;
gcMemoryLimitMB?: number;
}
export interface DiscoveryDiagnostic {

View file

@ -5,10 +5,12 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"os"
"os/user"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
@ -24,6 +26,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
"github.com/rs/zerolog/log"
"github.com/shirou/gopsutil/v4/process"
"golang.org/x/crypto/ssh"
"golang.org/x/sync/singleflight"
)
@ -593,12 +596,65 @@ type PBSDetails struct {
// SystemDiagnostic contains system-level diagnostic info
type SystemDiagnostic struct {
OS string `json:"os"`
Arch string `json:"arch"`
GoVersion string `json:"goVersion"`
NumCPU int `json:"numCPU"`
NumGoroutine int `json:"numGoroutine"`
MemoryMB uint64 `json:"memoryMB"`
OS string `json:"os"`
Arch string `json:"arch"`
GoVersion string `json:"goVersion"`
NumCPU int `json:"numCPU"`
NumGoroutine int `json:"numGoroutine"`
MemoryMB uint64 `json:"memoryMB"`
ProcessRSSMB uint64 `json:"processRssMB,omitempty"`
HeapAllocMB uint64 `json:"heapAllocMB,omitempty"`
HeapInUseMB uint64 `json:"heapInUseMB,omitempty"`
HeapIdleMB uint64 `json:"heapIdleMB,omitempty"`
HeapReleasedMB uint64 `json:"heapReleasedMB,omitempty"`
RuntimeRetainedMB uint64 `json:"runtimeRetainedMB,omitempty"`
GCMemoryLimitMB uint64 `json:"gcMemoryLimitMB,omitempty"`
}
const bytesPerMiB = uint64(1024 * 1024)
func bytesToMiB(value uint64) uint64 {
return value / bytesPerMiB
}
func currentProcessRSS() uint64 {
p, err := process.NewProcess(int32(os.Getpid()))
if err != nil {
return 0
}
info, err := p.MemoryInfo()
if err != nil || info == nil {
return 0
}
return info.RSS
}
func buildSystemDiagnostic(memStats runtime.MemStats, processRSS uint64, gcMemoryLimit int64) SystemDiagnostic {
runtimeRetained := memStats.Sys
if memStats.HeapReleased < runtimeRetained {
runtimeRetained -= memStats.HeapReleased
} else {
runtimeRetained = 0
}
diagnostic := SystemDiagnostic{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
GoVersion: runtime.Version(),
NumCPU: runtime.NumCPU(),
NumGoroutine: runtime.NumGoroutine(),
MemoryMB: bytesToMiB(memStats.Alloc), // Backward-compatible live-heap field.
ProcessRSSMB: bytesToMiB(processRSS),
HeapAllocMB: bytesToMiB(memStats.Alloc),
HeapInUseMB: bytesToMiB(memStats.HeapInuse),
HeapIdleMB: bytesToMiB(memStats.HeapIdle),
HeapReleasedMB: bytesToMiB(memStats.HeapReleased),
RuntimeRetainedMB: bytesToMiB(runtimeRetained),
}
if gcMemoryLimit > 0 && gcMemoryLimit < math.MaxInt64 {
diagnostic.GCMemoryLimitMB = bytesToMiB(uint64(gcMemoryLimit))
}
return diagnostic
}
// APITokenDiagnostic reports on the state of the multi-token authentication system.
@ -1005,14 +1061,7 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
// System info
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
diag.System = SystemDiagnostic{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
GoVersion: runtime.Version(),
NumCPU: runtime.NumCPU(),
NumGoroutine: runtime.NumGoroutine(),
MemoryMB: memStats.Alloc / 1024 / 1024,
}
diag.System = buildSystemDiagnostic(memStats, currentProcessRSS(), debug.SetMemoryLimit(-1))
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
diag.MetricsStore = buildMetricsStoreDiagnostic(r.monitor)

View file

@ -0,0 +1,39 @@
package api
import (
"math"
"runtime"
"testing"
)
func TestBuildSystemDiagnosticSeparatesHeapRuntimeAndRSS(t *testing.T) {
const mib = uint64(1024 * 1024)
memStats := runtime.MemStats{
Alloc: 20 * mib,
HeapInuse: 24 * mib,
HeapIdle: 40 * mib,
HeapReleased: 16 * mib,
Sys: 80 * mib,
}
got := buildSystemDiagnostic(memStats, 96*mib, int64(230*mib))
if got.MemoryMB != 20 || got.HeapAllocMB != 20 {
t.Fatalf("live heap = legacy %d explicit %d, want 20", got.MemoryMB, got.HeapAllocMB)
}
if got.HeapInUseMB != 24 || got.HeapIdleMB != 40 || got.HeapReleasedMB != 16 {
t.Fatalf("heap breakdown = in-use %d idle %d released %d", got.HeapInUseMB, got.HeapIdleMB, got.HeapReleasedMB)
}
if got.RuntimeRetainedMB != 64 {
t.Fatalf("runtime retained = %d, want Sys - HeapReleased = 64", got.RuntimeRetainedMB)
}
if got.ProcessRSSMB != 96 || got.GCMemoryLimitMB != 230 {
t.Fatalf("process/limit = RSS %d limit %d, want 96/230", got.ProcessRSSMB, got.GCMemoryLimitMB)
}
}
func TestBuildSystemDiagnosticOmitsUnlimitedMemoryLimit(t *testing.T) {
got := buildSystemDiagnostic(runtime.MemStats{}, 0, math.MaxInt64)
if got.GCMemoryLimitMB != 0 {
t.Fatalf("unlimited GC memory limit = %d, want omitted zero", got.GCMemoryLimitMB)
}
}

View file

@ -12,6 +12,7 @@ import (
"net/textproto"
"strconv"
"strings"
"sync"
"testing"
"github.com/gorilla/websocket"
@ -96,6 +97,45 @@ func TestBoundedGzipWriterPoolSupportsNoIdleRetention(t *testing.T) {
}
}
func TestWithGzipBoundsIdleWritersAfterConcurrentBurst(t *testing.T) {
const (
capacity = 2
concurrency = 8
)
previousPool := gzipWriterPool
gzipWriterPool = newBoundedGzipWriterPool(capacity)
t.Cleanup(func() { gzipWriterPool = previousPool })
started := make(chan struct{}, concurrency)
release := make(chan struct{})
handler := withGzip(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(strings.Repeat("burst-payload", 1024)))
started <- struct{}{}
<-release
}))
var wg sync.WaitGroup
for range concurrency {
wg.Add(1)
go func() {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
req.Header.Set("Accept-Encoding", "gzip")
handler.ServeHTTP(httptest.NewRecorder(), req)
}()
}
for range concurrency {
<-started
}
close(release)
wg.Wait()
if got := len(gzipWriterPool.idle); got != capacity {
t.Fatalf("idle gzip writers after burst = %d, want bounded capacity %d", got, capacity)
}
}
func TestWithGzipPassesThroughWithoutAcceptEncoding(t *testing.T) {
payload := strings.Repeat("plain body ", 500)
handler := withGzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -105,12 +105,19 @@ func readCgroupV2MemoryLimit(root, procSelfCgroup string) (int64, bool) {
// cgroupV2PathFrom extracts the unified-hierarchy path from /proc/self/cgroup
// content ("0::/system.slice/pulse.service" -> "system.slice/pulse.service").
// A process in a cgroup namespace commonly sees its own cgroup as "0::/";
// filepath's "." preserves that valid root path without conflating it with a
// missing unified-hierarchy entry.
func cgroupV2PathFrom(content string) string {
for _, line := range strings.Split(content, "\n") {
if !strings.HasPrefix(line, "0::") {
continue
}
return strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "0::")), "/")
path := strings.TrimSpace(strings.TrimPrefix(line, "0::"))
if path == "/" {
return "."
}
return strings.TrimPrefix(path, "/")
}
return ""
}

View file

@ -39,7 +39,7 @@ func TestCgroupV2PathFrom(t *testing.T) {
want string
}{
{"systemd service", "0::/system.slice/pulse.service\n", "system.slice/pulse.service"},
{"container root", "0::/\n", ""},
{"container root", "0::/\n", "."},
{"hybrid picks unified line", "12:memory:/legacy\n0::/system.slice/pulse.service\n", "system.slice/pulse.service"},
{"v1 only", "12:memory:/legacy\n", ""},
{"empty", "", ""},
@ -53,6 +53,18 @@ func TestCgroupV2PathFrom(t *testing.T) {
}
}
func TestReadCgroupV2MemoryLimitAtNamespacedRoot(t *testing.T) {
root := t.TempDir()
proc := filepath.Join(root, "proc-self-cgroup")
writeCgroupFixture(t, proc, "0::/\n")
writeCgroupFixture(t, filepath.Join(root, "memory.max"), "536870912\n")
got, ok := readCgroupV2MemoryLimit(root, proc)
if !ok || got != 536870912 {
t.Fatalf("got (%d, %v), want (536870912, true)", got, ok)
}
}
func writeCgroupFixture(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {