Source file src/internal/cgrouptest/cgrouptest_linux.go
1 // Copyright 2025 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package cgrouptest provides best-effort helpers for running tests inside a 6 // cgroup. 7 package cgrouptest 8 9 import ( 10 "fmt" 11 "internal/runtime/cgroup" 12 "os" 13 "path/filepath" 14 "slices" 15 "strconv" 16 "strings" 17 "syscall" 18 "testing" 19 ) 20 21 type CgroupV2 struct { 22 orig string 23 path string 24 } 25 26 func (c *CgroupV2) Path() string { 27 return c.path 28 } 29 30 // Path to cpu.max. 31 func (c *CgroupV2) CPUMaxPath() string { 32 return filepath.Join(c.path, "cpu.max") 33 } 34 35 // Set cpu.max. Pass -1 for quota to disable the limit. 36 func (c *CgroupV2) SetCPUMax(quota, period int64) error { 37 q := "max" 38 if quota >= 0 { 39 q = strconv.FormatInt(quota, 10) 40 } 41 buf := fmt.Sprintf("%s %d", q, period) 42 return os.WriteFile(c.CPUMaxPath(), []byte(buf), 0) 43 } 44 45 // InCgroupV2 creates a new v2 cgroup, migrates the current process into it, 46 // and then calls fn. When fn returns, the current process is migrated back to 47 // the original cgroup and the new cgroup is destroyed. 48 // 49 // If a new cgroup cannot be created, the test is skipped. 50 // 51 // This must not be used in parallel tests, as it affects the entire process. 52 func InCgroupV2(t *testing.T, fn func(*CgroupV2)) { 53 mount, rel := findCurrent(t) 54 parent := findOwnedParent(t, mount, rel) 55 orig := filepath.Join(mount, rel) 56 57 // Make sure the parent allows children to control cpu. 58 b, err := os.ReadFile(filepath.Join(parent, "cgroup.subtree_control")) 59 if err != nil { 60 t.Skipf("unable to read cgroup.subtree_control: %v", err) 61 } 62 if !slices.Contains(strings.Fields(string(b)), "cpu") { 63 // N.B. We should have permission to add cpu to 64 // subtree_control, but it seems like a bad idea to change this 65 // on a high-level cgroup that probably has lots of existing 66 // children. 67 t.Skipf("Parent cgroup %s does not allow children to control cpu, only %q", parent, string(b)) 68 } 69 70 path, err := os.MkdirTemp(parent, "go-cgrouptest") 71 if err != nil { 72 t.Skipf("unable to create cgroup directory: %v", err) 73 } 74 // Important: defer cleanups so they run even in the event of panic. 75 // 76 // TODO(prattmic): Consider running everything in a subprocess just so 77 // we can clean up if it throws or otherwise doesn't run the defers. 78 defer func() { 79 if err := os.Remove(path); err != nil { 80 // Not much we can do, but at least inform of the 81 // problem. 82 t.Errorf("Error removing cgroup directory: %v", err) 83 } 84 }() 85 86 migrateTo(t, path) 87 defer migrateTo(t, orig) 88 89 c := &CgroupV2{ 90 orig: orig, 91 path: path, 92 } 93 fn(c) 94 } 95 96 // Returns the mount and relative directory of the current cgroup the process 97 // is in. 98 func findCurrent(t *testing.T) (string, string) { 99 // Find the path to our current CPU cgroup. Currently this package is 100 // only used for CPU cgroup testing, so the distinction of different 101 // controllers doesn't matter. 102 var scratch [cgroup.ParseSize]byte 103 buf := make([]byte, cgroup.PathSize) 104 n, err := cgroup.FindCPUMountPoint(buf, scratch[:]) 105 if err != nil { 106 t.Skipf("cgroup: unable to find current cgroup mount: %v", err) 107 } 108 mount := string(buf[:n]) 109 110 n, ver, err := cgroup.FindCPURelativePath(buf, scratch[:]) 111 if err != nil { 112 t.Skipf("cgroup: unable to find current cgroup path: %v", err) 113 } 114 if ver != cgroup.V2 { 115 t.Skipf("cgroup: running on cgroup v%d want v2", ver) 116 } 117 rel := string(buf[1:n]) // The returned path always starts with /, skip it. 118 rel = filepath.Join(".", rel) // Make sure this isn't empty string at root. 119 return mount, rel 120 } 121 122 // Returns a parent directory in which we can create our own cgroup subdirectory. 123 func findOwnedParent(t *testing.T, mount, rel string) string { 124 // There are many ways cgroups may be set up on a system. We don't try 125 // to cover all of them, just common ones. 126 // 127 // To start with, systemd: 128 // 129 // Our test process is likely running inside a user session, in which 130 // case we are likely inside a cgroup that looks something like: 131 // 132 // /sys/fs/cgroup/user.slice/user-1234.slice/user@1234.service/vte-spawn-1.scope/ 133 // 134 // Possibly with additional slice layers between user@1234.service and 135 // the leaf scope. 136 // 137 // On new enough kernel and systemd versions (exact versions unknown), 138 // full unprivileged control of the user's cgroups is permitted 139 // directly via the cgroup filesystem. Specifically, the 140 // user@1234.service directory is owned by the user, as are all 141 // subdirectories. 142 143 // We want to create our own subdirectory that we can migrate into and 144 // then manipulate at will. It is tempting to create a new subdirectory 145 // inside the current cgroup we are already in, however that will likey 146 // not work. cgroup v2 only allows processes to be in leaf cgroups. Our 147 // current cgroup likely contains multiple processes (at least this one 148 // and the cmd/go test runner). If we make a subdirectory and try to 149 // move our process into that cgroup, then the subdirectory and parent 150 // would both contain processes. Linux won't allow us to do that [1]. 151 // 152 // Instead, we will simply walk up to the highest directory that our 153 // user owns and create our new subdirectory. Since that directory 154 // already has a bunch of subdirectories, it must not directly contain 155 // and processes. 156 // 157 // (This would fall apart if we already in the highest directory we 158 // own, such as if there was simply a single cgroup for the entire 159 // user. Luckily systemd at least does not do this.) 160 // 161 // [1] Minor technicality: By default a new subdirectory has no cgroup 162 // controller (they must be explicitly enabled in the parent's 163 // cgroup.subtree_control). Linux will allow moving processes into a 164 // subdirectory that has no controllers while there are still processes 165 // in the parent, but it won't allow adding controller until the parent 166 // is empty. As far as I tell, the only purpose of this is to allow 167 // reorganizing processes into a new set of subdirectories and then 168 // adding controllers once done. 169 root, err := os.OpenRoot(mount) 170 if err != nil { 171 t.Fatalf("error opening cgroup mount root: %v", err) 172 } 173 174 uid := os.Getuid() 175 var prev string 176 for rel != "." { 177 fi, err := root.Stat(rel) 178 if err != nil { 179 t.Fatalf("error stating cgroup path: %v", err) 180 } 181 182 st := fi.Sys().(*syscall.Stat_t) 183 if int(st.Uid) != uid { 184 // Stop at first directory we don't own. 185 break 186 } 187 188 prev = rel 189 rel = filepath.Join(rel, "..") 190 } 191 192 if prev == "" { 193 t.Skipf("No parent cgroup owned by UID %d", uid) 194 } 195 196 // We actually want the last directory where we were the owner. 197 return filepath.Join(mount, prev) 198 } 199 200 // Migrate the current process to the cgroup directory dst. 201 func migrateTo(t *testing.T, dst string) { 202 pid := []byte(strconv.FormatInt(int64(os.Getpid()), 10)) 203 if err := os.WriteFile(filepath.Join(dst, "cgroup.procs"), pid, 0); err != nil { 204 t.Skipf("Unable to migrate into %s: %v", dst, err) 205 } 206 } 207