From 7a229de634516a2846cd61bb4f653dae2405974b Mon Sep 17 00:00:00 2001 From: Nakul Bharti Date: Sat, 17 Oct 2026 21:55:20 +0530 Subject: [PATCH 1/3] fix(memguardian): respect Linux cgroup memory limits --- memguardian/memory_linux.go | 145 ++++++++++++++++++++++++++++++- memguardian/memory_linux_test.go | 129 +++++++++++++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 memguardian/memory_linux_test.go diff --git a/memguardian/memory_linux.go b/memguardian/memory_linux.go index 207c3be0..7709e6f4 100644 --- a/memguardian/memory_linux.go +++ b/memguardian/memory_linux.go @@ -2,9 +2,20 @@ package memguardian -import "syscall" +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) func getSysInfo() (*SysInfo, error) { + return getSysInfoForPaths("/proc/self/cgroup", "/sys/fs/cgroup") +} + +func getSysInfoForPaths(procCgroupPath, cgroupRoot string) (*SysInfo, error) { var sysInfo syscall.Sysinfo_t err := syscall.Sysinfo(&sysInfo) if err != nil { @@ -21,6 +32,138 @@ func getSysInfo() (*SysInfo, error) { FreeSwap: uint64(sysInfo.Freeswap), Unit: uint64(sysInfo.Unit), } + used, limit, limited, err := cgroupMemoryUsage(procCgroupPath, cgroupRoot) + if err != nil { + return nil, err + } + if limited && limit < si.TotalRam() { + si.totalRam = limit + si.freeRam = limit - used + si.Unit = 1 + } return si, nil } + +func cgroupMemoryUsage(procCgroupPath, cgroupRoot string) (uint64, uint64, bool, error) { + data, err := os.ReadFile(procCgroupPath) + if os.IsNotExist(err) { + return 0, 0, false, nil + } + if err != nil { + return 0, 0, false, fmt.Errorf("read process cgroup: %w", err) + } + + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, ":", 3) + if len(parts) != 3 { + continue + } + group := strings.TrimPrefix(filepath.Clean("/"+parts[2]), "/") + if parts[0] == "0" && parts[1] == "" { + return memoryUsageFromHierarchy( + filepath.Join(cgroupRoot, group), cgroupRoot, + "memory.current", "memory.max", "memory.stat", + "inactive_file", + ) + } + for _, controller := range strings.Split(parts[1], ",") { + if controller == "memory" { + root := filepath.Join(cgroupRoot, "memory") + return memoryUsageFromHierarchy( + filepath.Join(root, group), root, + "memory.usage_in_bytes", "memory.limit_in_bytes", "memory.stat", + "total_inactive_file", + ) + } + } + } + return 0, 0, false, nil +} + +func memoryUsageFromHierarchy(groupDir, rootDir, currentName, limitName, statName, inactiveFileKey string) (uint64, uint64, bool, error) { + rootDir = filepath.Clean(rootDir) + groupDir = filepath.Clean(groupDir) + var selectedUsed, selectedLimit uint64 + var limited bool + for { + used, limit, hasLimit, err := memoryUsageFromFiles( + filepath.Join(groupDir, currentName), + filepath.Join(groupDir, limitName), + filepath.Join(groupDir, statName), + inactiveFileKey, + ) + if err != nil { + return 0, 0, false, err + } + if hasLimit && (!limited || float64(used)/float64(limit) > float64(selectedUsed)/float64(selectedLimit)) { + selectedUsed, selectedLimit, limited = used, limit, true + } + if groupDir == rootDir { + break + } + parent := filepath.Dir(groupDir) + if parent == groupDir || (parent != rootDir && !strings.HasPrefix(parent, rootDir+string(filepath.Separator))) { + break + } + groupDir = parent + } + return selectedUsed, selectedLimit, limited, nil +} + +func memoryUsageFromFiles(currentPath, limitPath, statPath, inactiveFileKey string) (uint64, uint64, bool, error) { + limitData, err := os.ReadFile(limitPath) + if os.IsNotExist(err) { + return 0, 0, false, nil + } + if err != nil { + return 0, 0, false, fmt.Errorf("read cgroup memory limit: %w", err) + } + limitText := strings.TrimSpace(string(limitData)) + if limitText == "max" { + return 0, 0, false, nil + } + limit, err := strconv.ParseUint(limitText, 10, 64) + if err != nil { + return 0, 0, false, fmt.Errorf("parse cgroup memory limit: %w", err) + } + if limit >= 1<<60 { + return 0, 0, false, nil + } + if limit == 0 { + return 0, 0, false, fmt.Errorf("cgroup memory limit is zero") + } + + currentData, err := os.ReadFile(currentPath) + if err != nil { + return 0, 0, false, fmt.Errorf("read cgroup memory usage: %w", err) + } + current, err := strconv.ParseUint(strings.TrimSpace(string(currentData)), 10, 64) + if err != nil { + return 0, 0, false, fmt.Errorf("parse cgroup memory usage: %w", err) + } + statData, err := os.ReadFile(statPath) + if err != nil { + return 0, 0, false, fmt.Errorf("read cgroup memory stats: %w", err) + } + var inactive uint64 + for _, line := range strings.Split(string(statData), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != inactiveFileKey { + continue + } + inactive, err = strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return 0, 0, false, fmt.Errorf("parse cgroup inactive memory: %w", err) + } + break + } + if inactive > current { + inactive = current + } + used := current - inactive + if used > limit { + used = limit + } + return used, limit, true, nil +} diff --git a/memguardian/memory_linux_test.go b/memguardian/memory_linux_test.go new file mode 100644 index 00000000..c76c7855 --- /dev/null +++ b/memguardian/memory_linux_test.go @@ -0,0 +1,129 @@ +//go:build linux + +package memguardian + +import ( + "os" + "path/filepath" + "testing" +) + +func writeMemoryFixture(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0600); err != nil { + t.Fatal(err) + } +} + +func TestCgroupV2MemoryOverridesHostMemory(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "worker") + writeMemoryFixture(t, procPath, "0::/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.max"), "1000\n") + writeMemoryFixture(t, filepath.Join(group, "memory.current"), "900\n") + writeMemoryFixture(t, filepath.Join(group, "memory.stat"), "anon 800\ninactive_file 100\n") + + info, err := getSysInfoForPaths(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if got := info.TotalRam(); got != 1000 { + t.Fatalf("total RAM = %d, want 1000", got) + } + if got := info.UsedRam(); got != 800 { + t.Fatalf("used RAM = %d, want 800", got) + } + if got := info.UsedPercent(); got != 80 { + t.Fatalf("used percent = %f, want 80", got) + } +} + +func TestCgroupV1MemoryController(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "memory", "worker") + writeMemoryFixture(t, procPath, "7:cpu,cpuacct:/worker\n6:memory:/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.limit_in_bytes"), "1000\n") + writeMemoryFixture(t, filepath.Join(group, "memory.usage_in_bytes"), "950\n") + writeMemoryFixture(t, filepath.Join(group, "memory.stat"), "total_inactive_file 100\n") + + used, limit, limited, err := cgroupMemoryUsage(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if !limited || used != 850 || limit != 1000 { + t.Fatalf("cgroup memory = %d/%d limited=%t, want 850/1000 true", used, limit, limited) + } +} + +func TestCgroupV2UsesConstrainedAncestor(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + parent := filepath.Join(root, "sys", "parent") + child := filepath.Join(parent, "child") + writeMemoryFixture(t, procPath, "0::/parent/child\n") + writeMemoryFixture(t, filepath.Join(child, "memory.max"), "max\n") + writeMemoryFixture(t, filepath.Join(parent, "memory.max"), "1000\n") + writeMemoryFixture(t, filepath.Join(parent, "memory.current"), "950\n") + writeMemoryFixture(t, filepath.Join(parent, "memory.stat"), "inactive_file 50\n") + + used, limit, limited, err := cgroupMemoryUsage(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if !limited || used != 900 || limit != 1000 { + t.Fatalf("cgroup memory = %d/%d limited=%t, want 900/1000 true", used, limit, limited) + } +} + +func TestUnlimitedCgroupUsesHostMemory(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "worker") + writeMemoryFixture(t, procPath, "0::/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.max"), "max\n") + + info, err := getSysInfoForPaths(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if info.TotalRam() == 0 { + t.Fatal("unlimited cgroup did not fall back to host memory") + } +} + +func TestCgroupLimitAboveHostUsesHostMemory(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "worker") + writeMemoryFixture(t, procPath, "0::/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.max"), "36028797018963968\n") + writeMemoryFixture(t, filepath.Join(group, "memory.current"), "1000\n") + writeMemoryFixture(t, filepath.Join(group, "memory.stat"), "inactive_file 0\n") + + info, err := getSysInfoForPaths(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if info.TotalRam() >= 1<<55 { + t.Fatalf("total RAM = %d, want host memory below ineffective cgroup limit", info.TotalRam()) + } +} + +func TestInvalidLimitedCgroupDoesNotHideMemoryPressure(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "worker") + writeMemoryFixture(t, procPath, "0::/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.max"), "1000\n") + writeMemoryFixture(t, filepath.Join(group, "memory.current"), "invalid\n") + writeMemoryFixture(t, filepath.Join(group, "memory.stat"), "inactive_file 0\n") + + if _, err := getSysInfoForPaths(procPath, filepath.Join(root, "sys")); err == nil { + t.Fatal("expected an error for malformed limited cgroup usage") + } +} From e000ab727e6a71706f0d9707ee16cc441a4cb8b4 Mon Sep 17 00:00:00 2001 From: Nakul Bharti Date: Sat, 17 Oct 2026 21:57:15 +0530 Subject: [PATCH 2/3] test(memguardian): cover hybrid cgroup controller fallback --- memguardian/memory_linux.go | 6 +++++- memguardian/memory_linux_test.go | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/memguardian/memory_linux.go b/memguardian/memory_linux.go index 7709e6f4..8a51959f 100644 --- a/memguardian/memory_linux.go +++ b/memguardian/memory_linux.go @@ -61,11 +61,15 @@ func cgroupMemoryUsage(procCgroupPath, cgroupRoot string) (uint64, uint64, bool, } group := strings.TrimPrefix(filepath.Clean("/"+parts[2]), "/") if parts[0] == "0" && parts[1] == "" { - return memoryUsageFromHierarchy( + used, limit, limited, err := memoryUsageFromHierarchy( filepath.Join(cgroupRoot, group), cgroupRoot, "memory.current", "memory.max", "memory.stat", "inactive_file", ) + if err != nil || limited { + return used, limit, limited, err + } + continue } for _, controller := range strings.Split(parts[1], ",") { if controller == "memory" { diff --git a/memguardian/memory_linux_test.go b/memguardian/memory_linux_test.go index c76c7855..dd5d9b76 100644 --- a/memguardian/memory_linux_test.go +++ b/memguardian/memory_linux_test.go @@ -60,6 +60,24 @@ func TestCgroupV1MemoryController(t *testing.T) { } } +func TestHybridCgroupFallsBackToLimitedMemoryController(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + group := filepath.Join(root, "sys", "memory", "worker") + writeMemoryFixture(t, procPath, "0::/worker\n6:memory:/worker\n") + writeMemoryFixture(t, filepath.Join(group, "memory.limit_in_bytes"), "1000\n") + writeMemoryFixture(t, filepath.Join(group, "memory.usage_in_bytes"), "900\n") + writeMemoryFixture(t, filepath.Join(group, "memory.stat"), "total_inactive_file 100\n") + + used, limit, limited, err := cgroupMemoryUsage(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if !limited || used != 800 || limit != 1000 { + t.Fatalf("cgroup memory = %d/%d limited=%t, want 800/1000 true", used, limit, limited) + } +} + func TestCgroupV2UsesConstrainedAncestor(t *testing.T) { root := t.TempDir() procPath := filepath.Join(root, "proc", "cgroup") From ac02d12acdf3a10bb975aac85b6339a0afb9efb6 Mon Sep 17 00:00:00 2001 From: Nakul Bharti Date: Sun, 18 Oct 2026 00:00:53 +0530 Subject: [PATCH 3/3] fix(memguardian): resolve remapped cgroup mount roots --- memguardian/memory_linux.go | 48 ++++++++++++++++++++++++++++++-- memguardian/memory_linux_test.go | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/memguardian/memory_linux.go b/memguardian/memory_linux.go index 8a51959f..a553e5be 100644 --- a/memguardian/memory_linux.go +++ b/memguardian/memory_linux.go @@ -61,8 +61,12 @@ func cgroupMemoryUsage(procCgroupPath, cgroupRoot string) (uint64, uint64, bool, } group := strings.TrimPrefix(filepath.Clean("/"+parts[2]), "/") if parts[0] == "0" && parts[1] == "" { + groupDir, mountRoot, err := cgroupMountPath(procCgroupPath, "/"+group, "cgroup2", "", cgroupRoot) + if err != nil { + return 0, 0, false, err + } used, limit, limited, err := memoryUsageFromHierarchy( - filepath.Join(cgroupRoot, group), cgroupRoot, + groupDir, mountRoot, "memory.current", "memory.max", "memory.stat", "inactive_file", ) @@ -74,8 +78,12 @@ func cgroupMemoryUsage(procCgroupPath, cgroupRoot string) (uint64, uint64, bool, for _, controller := range strings.Split(parts[1], ",") { if controller == "memory" { root := filepath.Join(cgroupRoot, "memory") + groupDir, mountRoot, err := cgroupMountPath(procCgroupPath, "/"+group, "cgroup", "memory", root) + if err != nil { + return 0, 0, false, err + } return memoryUsageFromHierarchy( - filepath.Join(root, group), root, + groupDir, mountRoot, "memory.usage_in_bytes", "memory.limit_in_bytes", "memory.stat", "total_inactive_file", ) @@ -85,6 +93,42 @@ func cgroupMemoryUsage(procCgroupPath, cgroupRoot string) (uint64, uint64, bool, return 0, 0, false, nil } +func cgroupMountPath(procCgroupPath, groupPath, fsType, controller, fallbackRoot string) (string, string, error) { + data, err := os.ReadFile(filepath.Join(filepath.Dir(procCgroupPath), "mountinfo")) + if os.IsNotExist(err) { + return filepath.Join(fallbackRoot, groupPath), fallbackRoot, nil + } + if err != nil { + return "", "", fmt.Errorf("read process mountinfo: %w", err) + } + + for _, line := range strings.Split(string(data), "\n") { + sections := strings.SplitN(line, " - ", 2) + if len(sections) != 2 { + continue + } + mount := strings.Fields(sections[0]) + filesystem := strings.Fields(sections[1]) + if len(mount) < 5 || len(filesystem) < 3 || filesystem[0] != fsType { + continue + } + if controller != "" && !strings.Contains(","+filesystem[2]+",", ","+controller+",") { + continue + } + mountRoot, rootErr := strconv.Unquote("\"" + mount[3] + "\"") + mountPoint, pointErr := strconv.Unquote("\"" + mount[4] + "\"") + if rootErr != nil || pointErr != nil { + continue + } + relative, err := filepath.Rel(mountRoot, groupPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + continue + } + return filepath.Join(mountPoint, relative), mountPoint, nil + } + return filepath.Join(fallbackRoot, groupPath), fallbackRoot, nil +} + func memoryUsageFromHierarchy(groupDir, rootDir, currentName, limitName, statName, inactiveFileKey string) (uint64, uint64, bool, error) { rootDir = filepath.Clean(rootDir) groupDir = filepath.Clean(groupDir) diff --git a/memguardian/memory_linux_test.go b/memguardian/memory_linux_test.go index dd5d9b76..ea230662 100644 --- a/memguardian/memory_linux_test.go +++ b/memguardian/memory_linux_test.go @@ -60,6 +60,54 @@ func TestCgroupV1MemoryController(t *testing.T) { } } +func TestCgroupV1RemappedMountRoot(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + mountPoint := filepath.Join(root, "sys", "memory") + writeMemoryFixture(t, procPath, "6:memory:/docker/worker\n") + writeMemoryFixture(t, filepath.Join(root, "proc", "mountinfo"), + "27 22 0:28 /docker "+mountPoint+" rw - cgroup cgroup rw,memory\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.limit_in_bytes"), "1000\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.usage_in_bytes"), "320\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.stat"), "total_inactive_file 100\n") + worker := filepath.Join(mountPoint, "worker") + writeMemoryFixture(t, filepath.Join(worker, "memory.limit_in_bytes"), "400\n") + writeMemoryFixture(t, filepath.Join(worker, "memory.usage_in_bytes"), "300\n") + writeMemoryFixture(t, filepath.Join(worker, "memory.stat"), "total_inactive_file 100\n") + + used, limit, limited, err := cgroupMemoryUsage(procPath, filepath.Join(root, "sys")) + if err != nil { + t.Fatal(err) + } + if !limited || used != 200 || limit != 400 { + t.Fatalf("cgroup memory = %d/%d limited=%t, want 200/400 true", used, limit, limited) + } +} + +func TestCgroupV2RemappedMountRoot(t *testing.T) { + root := t.TempDir() + procPath := filepath.Join(root, "proc", "cgroup") + mountPoint := filepath.Join(root, "sys") + writeMemoryFixture(t, procPath, "0::/parent/worker\n") + writeMemoryFixture(t, filepath.Join(root, "proc", "mountinfo"), + "27 22 0:28 /parent "+mountPoint+" rw - cgroup2 cgroup rw\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.max"), "1000\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.current"), "320\n") + writeMemoryFixture(t, filepath.Join(mountPoint, "memory.stat"), "inactive_file 100\n") + worker := filepath.Join(mountPoint, "worker") + writeMemoryFixture(t, filepath.Join(worker, "memory.max"), "400\n") + writeMemoryFixture(t, filepath.Join(worker, "memory.current"), "300\n") + writeMemoryFixture(t, filepath.Join(worker, "memory.stat"), "inactive_file 100\n") + + used, limit, limited, err := cgroupMemoryUsage(procPath, mountPoint) + if err != nil { + t.Fatal(err) + } + if !limited || used != 200 || limit != 400 { + t.Fatalf("cgroup memory = %d/%d limited=%t, want 200/400 true", used, limit, limited) + } +} + func TestHybridCgroupFallsBackToLimitedMemoryController(t *testing.T) { root := t.TempDir() procPath := filepath.Join(root, "proc", "cgroup")