| // Copyright 2025 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // Package cgrouptest provides best-effort helpers for running tests inside a |
| // cgroup. |
| package cgrouptest |
| |
| import ( |
| "fmt" |
| "internal/runtime/cgroup" |
| "os" |
| "path/filepath" |
| "slices" |
| "strconv" |
| "strings" |
| "syscall" |
| "testing" |
| ) |
| |
| type CgroupV2 struct { |
| orig string |
| path string |
| } |
| |
| func (c *CgroupV2) Path() string { |
| return c.path |
| } |
| |
| // Path to cpu.max. |
| func (c *CgroupV2) CPUMaxPath() string { |
| return filepath.Join(c.path, "cpu.max") |
| } |
| |
| // Set cpu.max. Pass -1 for quota to disable the limit. |
| func (c *CgroupV2) SetCPUMax(quota, period int64) error { |
| q := "max" |
| if quota >= 0 { |
| q = strconv.FormatInt(quota, 10) |
| } |
| buf := fmt.Sprintf("%s %d", q, period) |
| return os.WriteFile(c.CPUMaxPath(), []byte(buf), 0) |
| } |
| |
| // InCgroupV2 creates a new v2 cgroup, migrates the current process into it, |
| // and then calls fn. When fn returns, the current process is migrated back to |
| // the original cgroup and the new cgroup is destroyed. |
| // |
| // If a new cgroup cannot be created, the test is skipped. |
| // |
| // This must not be used in parallel tests, as it affects the entire process. |
| func InCgroupV2(t *testing.T, fn func(*CgroupV2)) { |
| mount, rel := findCurrent(t) |
| parent := findOwnedParent(t, mount, rel) |
| orig := filepath.Join(mount, rel) |
| |
| // Make sure the parent allows children to control cpu. |
| b, err := os.ReadFile(filepath.Join(parent, "cgroup.subtree_control")) |
| if err != nil { |
| t.Skipf("unable to read cgroup.subtree_control: %v", err) |
| } |
| if !slices.Contains(strings.Fields(string(b)), "cpu") { |
| // N.B. We should have permission to add cpu to |
| // subtree_control, but it seems like a bad idea to change this |
| // on a high-level cgroup that probably has lots of existing |
| // children. |
| t.Skipf("Parent cgroup %s does not allow children to control cpu, only %q", parent, string(b)) |
| } |
| |
| path, err := os.MkdirTemp(parent, "go-cgrouptest") |
| if err != nil { |
| t.Skipf("unable to create cgroup directory: %v", err) |
| } |
| // Important: defer cleanups so they run even in the event of panic. |
| // |
| // TODO(prattmic): Consider running everything in a subprocess just so |
| // we can clean up if it throws or otherwise doesn't run the defers. |
| defer func() { |
| if err := os.Remove(path); err != nil { |
| // Not much we can do, but at least inform of the |
| // problem. |
| t.Errorf("Error removing cgroup directory: %v", err) |
| } |
| }() |
| |
| migrateTo(t, path) |
| defer migrateTo(t, orig) |
| |
| c := &CgroupV2{ |
| orig: orig, |
| path: path, |
| } |
| fn(c) |
| } |
| |
| // Returns the mount and relative directory of the current cgroup the process |
| // is in. |
| func findCurrent(t *testing.T) (string, string) { |
| // Find the path to our current CPU cgroup. Currently this package is |
| // only used for CPU cgroup testing, so the distinction of different |
| // controllers doesn't matter. |
| var scratch [cgroup.ParseSize]byte |
| buf := make([]byte, cgroup.PathSize) |
| n, err := cgroup.FindCPUMountPoint(buf, scratch[:]) |
| if err != nil { |
| t.Skipf("cgroup: unable to find current cgroup mount: %v", err) |
| } |
| mount := string(buf[:n]) |
| |
| n, ver, err := cgroup.FindCPURelativePath(buf, scratch[:]) |
| if err != nil { |
| t.Skipf("cgroup: unable to find current cgroup path: %v", err) |
| } |
| if ver != cgroup.V2 { |
| t.Skipf("cgroup: running on cgroup v%d want v2", ver) |
| } |
| rel := string(buf[1:n]) // The returned path always starts with /, skip it. |
| rel = filepath.Join(".", rel) // Make sure this isn't empty string at root. |
| return mount, rel |
| } |
| |
| // Returns a parent directory in which we can create our own cgroup subdirectory. |
| func findOwnedParent(t *testing.T, mount, rel string) string { |
| // There are many ways cgroups may be set up on a system. We don't try |
| // to cover all of them, just common ones. |
| // |
| // To start with, systemd: |
| // |
| // Our test process is likely running inside a user session, in which |
| // case we are likely inside a cgroup that looks something like: |
| // |
| // /sys/fs/cgroup/user.slice/user-1234.slice/user@1234.service/vte-spawn-1.scope/ |
| // |
| // Possibly with additional slice layers between user@1234.service and |
| // the leaf scope. |
| // |
| // On new enough kernel and systemd versions (exact versions unknown), |
| // full unprivileged control of the user's cgroups is permitted |
| // directly via the cgroup filesystem. Specifically, the |
| // user@1234.service directory is owned by the user, as are all |
| // subdirectories. |
| |
| // We want to create our own subdirectory that we can migrate into and |
| // then manipulate at will. It is tempting to create a new subdirectory |
| // inside the current cgroup we are already in, however that will likey |
| // not work. cgroup v2 only allows processes to be in leaf cgroups. Our |
| // current cgroup likely contains multiple processes (at least this one |
| // and the cmd/go test runner). If we make a subdirectory and try to |
| // move our process into that cgroup, then the subdirectory and parent |
| // would both contain processes. Linux won't allow us to do that [1]. |
| // |
| // Instead, we will simply walk up to the highest directory that our |
| // user owns and create our new subdirectory. Since that directory |
| // already has a bunch of subdirectories, it must not directly contain |
| // and processes. |
| // |
| // (This would fall apart if we already in the highest directory we |
| // own, such as if there was simply a single cgroup for the entire |
| // user. Luckily systemd at least does not do this.) |
| // |
| // [1] Minor technicality: By default a new subdirectory has no cgroup |
| // controller (they must be explicitly enabled in the parent's |
| // cgroup.subtree_control). Linux will allow moving processes into a |
| // subdirectory that has no controllers while there are still processes |
| // in the parent, but it won't allow adding controller until the parent |
| // is empty. As far as I tell, the only purpose of this is to allow |
| // reorganizing processes into a new set of subdirectories and then |
| // adding controllers once done. |
| root, err := os.OpenRoot(mount) |
| if err != nil { |
| t.Fatalf("error opening cgroup mount root: %v", err) |
| } |
| |
| uid := os.Getuid() |
| var prev string |
| for rel != "." { |
| fi, err := root.Stat(rel) |
| if err != nil { |
| t.Fatalf("error stating cgroup path: %v", err) |
| } |
| |
| st := fi.Sys().(*syscall.Stat_t) |
| if int(st.Uid) != uid { |
| // Stop at first directory we don't own. |
| break |
| } |
| |
| prev = rel |
| rel = filepath.Join(rel, "..") |
| } |
| |
| if prev == "" { |
| t.Skipf("No parent cgroup owned by UID %d", uid) |
| } |
| |
| // We actually want the last directory where we were the owner. |
| return filepath.Join(mount, prev) |
| } |
| |
| // Migrate the current process to the cgroup directory dst. |
| func migrateTo(t *testing.T, dst string) { |
| pid := []byte(strconv.FormatInt(int64(os.Getpid()), 10)) |
| if err := os.WriteFile(filepath.Join(dst, "cgroup.procs"), pid, 0); err != nil { |
| t.Skipf("Unable to migrate into %s: %v", dst, err) |
| } |
| } |