mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Honor namespaced cgroup memory limits
This commit is contained in:
parent
a966264bb1
commit
1a48d3cbd6
8 changed files with 247 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
39
internal/api/diagnostics_system_test.go
Normal file
39
internal/api/diagnostics_system_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue