zdd plan 2 task 4 fix: the hog is bounded and its confinement checked first
The hog child now waits for a "go" line on stdin before it allocates. The parent gives it only after reading back that g2's memory.max is 64M and that the hog's /proc/<pid>/cgroup is g2, so a StartChild that fell back to "spawning unaccounted" fails the test instead of letting the hog loose. The hog is bounded: a 256 MiB populating mmap, then at most 768 MiB of touch loop, then exit 3 with a message. A failed mmap exits 2 with the error on stderr, and EOF on stdin exits 4, so none of them can read as "not OOM-killed within 60s". Child stderr now goes to the test's stderr. Re-run on DOCS-T1 under `systemd-run --user -p Delegate=yes`: both PASS (OOM 14.20s, strict 0.05s). Plain go test: all three SKIP. The dev-box run was not done (needs Paul's approval).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+43
−5
internal/cgroup/gen_kernel_linux_test.go+43 −5
diff --git a/internal/cgroup/gen_kernel_linux_test.go b/internal/cgroup/gen_kernel_linux_test.go| index 099674e19..217a07d52 100644 |
| --- a/internal/cgroup/gen_kernel_linux_test.go |
| +++ b/internal/cgroup/gen_kernel_linux_test.go |
| @@ -56,6 +56,7 @@ func genKernelManager(t *testing.T) *Manager { |
| func genKernelCmd(mode string) *exec.Cmd { |
| cmd := exec.Command(os.Args[0], "-test.run=^TestGenKernelChild$", "-test.count=1") |
| cmd.Env = []string{"CARLOS_GEN_CHILD=" + mode} |
| + cmd.Stderr = os.Stderr // a child that fails says why |
| return cmd |
| } |
| @@ -72,22 +73,35 @@ func TestGenKernelChild(t *testing.T) { |
| } |
| os.Exit(0) |
| case "hog": |
| + // Wait for the parent's go-ahead, which it gives only after it has |
| + // checked that this process is inside its capped cgroup. EOF means |
| + // the parent is gone: exit rather than allocate unwatched. |
| + if _, err := bufio.NewReader(os.Stdin).ReadString('\n'); err != nil { |
| + fmt.Fprintln(os.Stderr, "hog: no go-ahead:", err) |
| + os.Exit(4) |
| + } |
| // One populating mmap: the kernel faults every page in without |
| // returning to user space, so memory.high's throttle (a sleep on |
| // each return) cannot slow the climb to memory.max. A hog that |
| // touches pages one at a time crawls at the throttle, swaps, and |
| // can sit under the limit for minutes instead of dying. |
| - if mem, err := syscall.Mmap(-1, 0, 512<<20, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE|syscall.MAP_POPULATE); err == nil { |
| - _ = mem |
| + mem, err := syscall.Mmap(-1, 0, 256<<20, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE|syscall.MAP_POPULATE) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, "hog: mmap:", err) |
| + os.Exit(2) |
| } |
| - var hold [][]byte |
| - for { |
| + _ = mem |
| + // Bounded: a hog that survives 1 GiB in all was not confined. |
| + hold := make([][]byte, 0, 192) |
| + for len(hold) < cap(hold) { |
| b := make([]byte, 4<<20) |
| for i := 0; i < len(b); i += 4096 { |
| b[i] = 1 |
| } |
| hold = append(hold, b) |
| } |
| + fmt.Fprintln(os.Stderr, "hog: survived 1 GiB: not confined") |
| + os.Exit(3) |
| default: |
| t.Skip("synthetic child only") |
| } |
| @@ -217,10 +231,34 @@ func TestGenOOMKernel(t *testing.T) { |
| } |
| expectFile(t, filepath.Join(parent, "memory.max"), "134217728") |
| - hog, err := m.StartChild(host, GenChild(2), small, small, func() *exec.Cmd { return genKernelCmd("hog") }) |
| + var hogIn io.WriteCloser |
| + hog, err := m.StartChild(host, GenChild(2), small, small, func() *exec.Cmd { |
| + cmd := genKernelCmd("hog") |
| + hogIn, _ = cmd.StdinPipe() |
| + return cmd |
| + }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| + // StartChild is best-effort: a refused limit write starts the child |
| + // uncapped. The hog waits on stdin, so prove it is confined first. |
| + confined := func(ok bool, format string, args ...any) { |
| + t.Helper() |
| + if !ok { |
| + _ = hog.Process.Kill() |
| + _ = hog.Wait() |
| + t.Fatalf(format, args...) |
| + } |
| + } |
| + g2 := filepath.Join(parent, GenChild(2)) |
| + wantG2 := filepath.Join(strings.TrimPrefix(m.Root(), "/sys/fs/cgroup/"), ExecName(host), GenChild(2)) |
| + got, err := os.ReadFile(filepath.Join(g2, "memory.max")) |
| + confined(err == nil && strings.TrimSpace(string(got)) == "67108864", "g2 memory.max = %q, %v; want 67108864 before the hog runs", got, err) |
| + gotCg := cgroupOf(t, hog.Process.Pid) |
| + confined(gotCg == wantG2, "hog runs in %s, want %s", gotCg, wantG2) |
| + if _, err := fmt.Fprintln(hogIn, "go"); err != nil { |
| + confined(false, "hog go-ahead: %v", err) |
| + } |
| waited := make(chan error, 1) |
| go func() { waited <- hog.Wait() }() |
| select { |