carlos / platform Public

cgroup: a generation start reads the PID-move latch once and is never moved

Task 7 fix round 1: one latch read decides a required start, with a
guard before the start-and-move tail; ENOENT/EACCES from a failed clone3
are classified by probing the child cgroup, ENOMEM/EAGAIN are the box's;
the required path logs only its refusal; FakePlacementForTest panics
outside tests; stricter no-PID-move check; recovery control cleanup kill.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8530210ea8f4d25e364fb9af8333bc69d8d8d028 parent 9859390
8 files changed, +320 −55
  • internal/activator/exec_gate_test.go +11 −2
  • internal/activator/genparent_kernel_linux_test.go +1 −0
  • internal/cgroup/cgroup.go +58 −14
  • internal/cgroup/cgroup_linux.go +51 −15
  • internal/cgroup/cgroup_linux_test.go +144 −13
  • internal/cgroup/cgroup_other.go +1 −1
  • internal/cgroup/gen.go +5 −4
  • internal/cgroup/gen_test.go +49 −6
diff --git a/internal/activator/exec_gate_test.go b/internal/activator/exec_gate_test.go
index 33da67a70..cd25a8df4 100644
--- a/internal/activator/exec_gate_test.go
+++ b/internal/activator/exec_gate_test.go
@@ -595,8 +595,17 @@ func TestTheExecGateIsAskedAtEachCgroupStart(t *testing.T) {
}
gens := b.gens()
b.prepGone(t, gens[0])
- if got, err := os.ReadFile(filepath.Join(parent, cgroup.GenChild(gens[0]), "cgroup.procs")); err == nil && len(got) != 0 {
- t.Fatalf("generation %d's child cgroup was given pid %s by a PID move", gens[0], got)
+ // No PID move. In this fixture the child's limit files are ordinary
+ // files, so the refusal's rmdir leaves the child in place: it must
+ // still be there with its memory.max written (else the check below
+ // proves nothing), and with no cgroup.procs at all, which is the
+ // file a start-and-move writes the PID into.
+ child := filepath.Join(parent, cgroup.GenChild(gens[0]))
+ if _, err := os.Stat(filepath.Join(child, "memory.max")); err != nil {
+ t.Fatalf("generation %d's child cgroup has no limit written (%v): the start never reached its placement", gens[0], err)
+ }
+ if got, err := os.ReadFile(filepath.Join(child, "cgroup.procs")); !os.IsNotExist(err) {
+ t.Fatalf("generation %d's child cgroup has a cgroup.procs (%q, %v): a PID move wrote it", gens[0], got, err)
}
})
}
diff --git a/internal/activator/genparent_kernel_linux_test.go b/internal/activator/genparent_kernel_linux_test.go
index df9b0f111..57e7437e5 100644
--- a/internal/activator/genparent_kernel_linux_test.go
+++ b/internal/activator/genparent_kernel_linux_test.go
@@ -212,6 +212,7 @@ func genParentRecoveryControl(t *testing.T, b *ExecBackend, host, parent string,
if err != nil || g1 == nil {
t.Fatalf("g1 after the legacy process exited: %v, %v", g1, err)
}
+ t.Cleanup(func() { _ = g1.Process.Kill() }) // a failed check below must not leave g1 running
done := spawnStaysUp(t, "recovered g1", g1)
expectGenParent(t, parent)
expectCappedGen(t, host, parent, cgroup.GenChild(1), g1)
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go
index 50572d945..bd3ad6953 100644
--- a/internal/cgroup/cgroup.go
+++ b/internal/cgroup/cgroup.go
@@ -27,6 +27,7 @@ import (
"strings"
"sync"
"sync/atomic"
+ "testing"
)
// execPrefix is the ONE definition of the per-host exec cgroup naming —
@@ -386,12 +387,16 @@ func (m *Manager) StartGated(host string, limit Limit, build func() *exec.Cmd, b
// a temp directory is no cgroup, so the clone3 CLONE_INTO_CGROUP start
// answers EBADF there, and a generation start (StartChildGated) is never
// retried by start-and-move. Until t's cleanup, every start runs without
-// the cgroup fd, as if the kernel had placed it. t is a *testing.T; nothing
-// outside a test calls it.
+// the cgroup fd, as if the kernel had placed it. t is a *testing.T. It
+// panics outside a test binary. It swaps a package global, so a test that
+// calls it, and every other test in that binary, must not use t.Parallel.
func FakePlacementForTest(t interface {
Helper()
Cleanup(func())
}) {
+ if !testing.Testing() {
+ panic("cgroup: FakePlacementForTest called outside a test")
+ }
t.Helper()
orig := startCmd
startCmd = func(cmd *exec.Cmd) error {
@@ -437,9 +442,20 @@ func startPlain(build func() *exec.Cmd, before func() error) (*exec.Cmd, error)
// start-and-move. Without it (StartGated, the flat start) every one of
// those stays best-effort, exactly as before.
func (m *Manager) startIn(host, dir string, limit Limit, required bool, build func() *exec.Cmd, before func() error) (*exec.Cmd, error) {
- if required && (!intoCgroupSupported || m.fallback.Load()) {
- m.logf("cgroup: %s: clone3-into-cgroup is unavailable here, and a generation is never started then moved — generation start refused", host)
- return nil, fmt.Errorf("%w: %s: clone3-into-cgroup unavailable", ErrLimitNotApplied, host)
+ // clone3 is whether this start places its process with clone3
+ // CLONE_INTO_CGROUP. A generation start reads the latch ONCE, here, and
+ // that one value decides both the refusal below and the start further
+ // down (Task 7 fix round 1): a concurrent flat start may latch the
+ // fallback while this one makes its child and writes its limits, and a
+ // second read would then send the generation to start-and-move. The flat
+ // start keeps its one read where it always was, after its limits.
+ clone3 := false
+ if required {
+ clone3 = intoCgroupSupported && !m.fallback.Load()
+ if !clone3 {
+ m.logf("cgroup: %s: clone3-into-cgroup is unavailable here, and a generation is never started then moved — generation start refused", host)
+ return nil, fmt.Errorf("%w: %s: clone3-into-cgroup unavailable", ErrLimitNotApplied, host)
+ }
}
if err := os.MkdirAll(dir, 0o755); err != nil {
if required {
@@ -449,12 +465,15 @@ func (m *Manager) startIn(host, dir string, limit Limit, required bool, build fu
m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err)
return startPlain(build, before)
}
- if err := m.applyLimit(host, dir, limit); err != nil && required {
+ if err := m.applyLimit(host, dir, limit, required); err != nil && required {
_ = os.Remove(dir)
- m.logf("cgroup: %s: %s: generation start refused", host, filepath.Base(dir))
+ m.logf("cgroup: %s: %s: %v — generation start refused", host, filepath.Base(dir), err)
return nil, fmt.Errorf("%w: %s: %w", ErrLimitNotApplied, filepath.Base(dir), err)
}
- if intoCgroupSupported && !m.fallback.Load() {
+ if !required {
+ clone3 = intoCgroupSupported && !m.fallback.Load()
+ }
+ if clone3 {
f, err := os.Open(dir)
if err != nil {
if required {
@@ -486,9 +505,11 @@ func (m *Manager) startIn(host, dir string, limit Limit, required bool, build fu
// the cgroup was the cause, and nothing latches. A failure that
// is the placement's (placementFailed) is the box's condition;
// any other is the start's own, as for a flat start whose retry
- // failed too.
+ // failed too. placementFailed may probe dir, so it runs before
+ // the dir goes.
+ placement := placementFailed(firstErr, dir)
_ = os.Remove(dir)
- if placementFailed(firstErr) {
+ if placement {
m.logf("cgroup: %s: clone3-into-cgroup failed (%v) — generation start refused, not retried by PID move", host, firstErr)
return nil, fmt.Errorf("%w: %s: clone3-into-cgroup: %w", ErrLimitNotApplied, host, firstErr)
}
@@ -514,6 +535,15 @@ func (m *Manager) startIn(host, dir string, limit Limit, required bool, build fu
}
return retried, nil
}
+ if required {
+ // Unreachable while clone3 is read once above (a required start
+ // without it was refused before its child was made). Kept so no
+ // later edit can send a generation to start-and-move: a generation
+ // is never started outside its cgroup and moved by PID.
+ _ = os.Remove(dir)
+ m.logf("cgroup: %s: a generation start reached the PID-move fallback — generation start refused", host)
+ return nil, fmt.Errorf("%w: %s: a generation is never started then moved", ErrLimitNotApplied, host)
+ }
cmd, _, err := m.startAndMoveIn(host, dir, build, before)
return cmd, err
}
@@ -523,8 +553,11 @@ func (m *Manager) startIn(host, dir string, limit Limit, required bool, build fu
// control file that isn't ours) is one logged line and an uncapped child.
// It returns the first write that failed, for a caller that must not start
// without the whole limit (startIn's required); the flat start ignores it.
-// Written BEFORE the child is in the cgroup, so the cap holds from its
-// first page. memory.max first: if that one fails, high is pointless.
+// With required, the first failure returns at once and logs nothing here:
+// no child will run, so "runs uncapped" would be untrue, and startIn logs
+// the one refusal line (Task 7 fix round 1). Without it, every line is as
+// before. Written BEFORE the child is in the cgroup, so the cap holds from
+// its first page. memory.max first: if that one fails, high is pointless.
//
// The cap bounds the cgroup's TOTAL charge — anonymous memory plus the page
// cache its reads pull in — not RSS. A tenant whose cache alone exceeds
@@ -535,16 +568,21 @@ func (m *Manager) startIn(host, dir string, limit Limit, required bool, build fu
// memory.oom.group=1 makes an OOM kill inside this cgroup take the whole
// family: a hub whose children outlive it would otherwise leave orphans
// holding the socket while the activator respawns a fresh hub beside them.
-func (m *Manager) applyLimit(host, dir string, limit Limit) error {
+func (m *Manager) applyLimit(host, dir string, limit Limit, required bool) error {
if limit.MemoryMax <= 0 {
return nil
}
if err := os.WriteFile(filepath.Join(dir, "memory.max"), []byte(strconv.FormatInt(limit.MemoryMax, 10)), 0o644); err != nil {
- m.logf("cgroup: %s: memory.max: %v — child runs uncapped", host, err)
+ if !required {
+ m.logf("cgroup: %s: memory.max: %v — child runs uncapped", host, err)
+ }
return fmt.Errorf("memory.max: %w", err)
}
var first error
if err := os.WriteFile(filepath.Join(dir, "memory.high"), []byte(strconv.FormatInt(HighOf(limit.MemoryMax), 10)), 0o644); err != nil {
+ if required {
+ return fmt.Errorf("memory.high: %w", err)
+ }
m.logf("cgroup: %s: memory.high: %v — child is capped but not throttled first", host, err)
first = fmt.Errorf("memory.high: %w", err)
}
@@ -555,12 +593,18 @@ func (m *Manager) applyLimit(host, dir string, limit Limit) error {
// max: a tenant may spill up to its cap into swap (the slow-then-dead
// gradient), then the kill is real.
if err := os.WriteFile(filepath.Join(dir, "memory.swap.max"), []byte(strconv.FormatInt(limit.MemoryMax, 10)), 0o644); err != nil {
+ if required {
+ return fmt.Errorf("memory.swap.max: %w", err)
+ }
m.logf("cgroup: %s: memory.swap.max: %v — the cap can be exceeded into swap", host, err)
if first == nil {
first = fmt.Errorf("memory.swap.max: %w", err)
}
}
if err := os.WriteFile(filepath.Join(dir, "memory.oom.group"), []byte("1"), 0o644); err != nil {
+ if required {
+ return fmt.Errorf("memory.oom.group: %w", err)
+ }
m.logf("cgroup: %s: memory.oom.group: %v — an OOM kill here takes one process, not the family", host, err)
if first == nil {
first = fmt.Errorf("memory.oom.group: %w", err)
diff --git a/internal/cgroup/cgroup_linux.go b/internal/cgroup/cgroup_linux.go
index 5448d97d9..864f6bba1 100644
--- a/internal/cgroup/cgroup_linux.go
+++ b/internal/cgroup/cgroup_linux.go
@@ -4,7 +4,9 @@ package cgroup
import (
"errors"
+ "os"
"os/exec"
+ "path/filepath"
"syscall"
)
@@ -28,26 +30,60 @@ func setInto(cmd *exec.Cmd, fd int) {
// for the life of this process — EPERM (a seccomp filter predating clone3,
// or one that forbids it outright) and ENOSYS (a kernel predating clone3)
// mean the mechanism itself is gone; EINVAL means the kernel understood
-// clone3 but rejected CLONE_INTO_CGROUP. Anything else (e.g. ENODEV from the
-// cgroup dir disappearing between os.Open and start, raced by a concurrent
-// CleanupHost) is spawn-specific and says nothing about the mechanism —
-// latching on it would permanently downgrade the process over a fluke and
-// log a lie ("refused").
+// clone3 but rejected CLONE_INTO_CGROUP. Anything else (e.g. ENOENT or
+// ENODEV from the cgroup dir disappearing between os.Open and start, raced
+// by a concurrent CleanupHost) is spawn-specific and says nothing about the
+// mechanism — latching on it would permanently downgrade the process over a
+// fluke and log a lie ("refused").
func shouldLatch(err error) bool {
return errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.ENOSYS) || errors.Is(err, syscall.EINVAL)
}
// placementFailed reports whether a failed clone3 CLONE_INTO_CGROUP start
-// failed on the placement rather than on the command: the mechanism refused
-// (shouldLatch), or the kernel would not place into the cgroup the fd names
-// (EBADF: not a cgroup; ENODEV: removed under the open fd; EBUSY: a domain
-// controller enabled in it; EOPNOTSUPP: a domain-invalid cgroup). An exec
-// failure (ENOENT, EACCES, ENOEXEC, ...) is the command's own. A generation
-// start is never retried by start-and-move, so this is the only evidence
-// of which it was (Task 7).
-func placementFailed(err error) bool {
- return shouldLatch(err) || errors.Is(err, syscall.EBADF) || errors.Is(err, syscall.ENODEV) ||
- errors.Is(err, syscall.EBUSY) || errors.Is(err, syscall.EOPNOTSUPP)
+// into dir failed on the placement or the box rather than on the command. A
+// generation start is never retried by start-and-move, so this is the only
+// evidence of which it was (Task 7). A placement or box failure:
+// - the mechanism refused (shouldLatch: EPERM, ENOSYS, EINVAL)
+// - the kernel would not place into the cgroup the fd names: EBADF (not
+// a cgroup), ENODEV (a cgroup the kernel has already marked dead),
+// EBUSY (a domain controller enabled in it), EOPNOTSUPP (a
+// domain-invalid cgroup)
+// - the box is out of room to fork: ENOMEM, or EAGAIN (a process or
+// pids.max limit)
+//
+// ENOENT and EACCES are ambiguous. Exec answers them for a missing or
+// unexecutable binary. The placement answers ENOENT for a cgroup removed
+// under the open fd, and EACCES when cgroup.procs is not writable. For
+// those two, dir is probed (placementLive): a child that is still a live,
+// writable cgroup puts the failure on the command; otherwise it is the
+// placement's. Any other error (ENOEXEC, ...) is the command's own.
+func placementFailed(err error, dir string) bool {
+ switch {
+ case shouldLatch(err), errors.Is(err, syscall.EBADF), errors.Is(err, syscall.ENODEV),
+ errors.Is(err, syscall.EBUSY), errors.Is(err, syscall.EOPNOTSUPP),
+ errors.Is(err, syscall.ENOMEM), errors.Is(err, syscall.EAGAIN):
+ return true
+ case errors.Is(err, syscall.ENOENT), errors.Is(err, syscall.EACCES):
+ return placementLive(dir) != nil
+ }
+ return false
+}
+
+// placementLive probes the cgroup a failed clone3 start was placing into:
+// dir must still be a live cgroup (its cgroup.events can be read) and its
+// cgroup.procs must open for writing. It writes nothing: the open without
+// O_TRUNC changes no file, and a cgroup.procs that is opened and closed
+// moves no process. A nil answer means the placement was sound, so the
+// start's failure was the command's.
+func placementLive(dir string) error {
+ if _, err := os.ReadFile(filepath.Join(dir, "cgroup.events")); err != nil {
+ return err
+ }
+ f, err := os.OpenFile(filepath.Join(dir, "cgroup.procs"), os.O_WRONLY, 0)
+ if err != nil {
+ return err
+ }
+ return f.Close()
}
// clearInto undoes setInto: the start runs without the cgroup fd.
diff --git a/internal/cgroup/cgroup_linux_test.go b/internal/cgroup/cgroup_linux_test.go
index e0e769d38..f397d7991 100644
--- a/internal/cgroup/cgroup_linux_test.go
+++ b/internal/cgroup/cgroup_linux_test.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strings"
"syscall"
"testing"
)
@@ -244,27 +245,42 @@ func TestStartGatedRefusedBeforeTheFirstStart(t *testing.T) {
}
// Task 7, G: a generation start whose clone3 placement fails is never
-// retried by start-and-move. A failure that is the placement's (the
-// mechanism gone: EPERM, ENOSYS, EINVAL; or a cgroup fd the kernel would
-// not place into: EBADF, ENODEV, EBUSY, EOPNOTSUPP) is ErrLimitNotApplied,
-// the box's condition. Any other failure (a missing binary) is the start's
-// own error. Neither latches (no retry proved the cgroup was the cause),
-// and nothing is moved by PID.
+// retried by start-and-move. A failure that is the placement's or the
+// box's is ErrLimitNotApplied: the mechanism gone (EPERM, ENOSYS, EINVAL),
+// a cgroup fd the kernel would not place into (EBADF, ENODEV, EBUSY,
+// EOPNOTSUPP), or no room to fork (ENOMEM, EAGAIN). ENOENT and EACCES are
+// the start's own error only while the child is still a live, writable
+// cgroup (fix round 1: placementLive); a removed or unwritable child makes
+// them the placement's. Nothing latches (no retry proved the cgroup was
+// the cause), and nothing is moved by PID.
func TestAGenerationStartIsNeverRetriedByPIDMove(t *testing.T) {
const host = "h.example.com"
for name, tc := range map[string]struct {
fail error
+ live bool // the child is still a live, writable cgroup at the failure
want error
}{
- "clone3 refused": {syscall.EPERM, ErrLimitNotApplied},
- "clone3 not understood": {syscall.EINVAL, ErrLimitNotApplied},
- "not a cgroup": {syscall.EBADF, ErrLimitNotApplied},
- "cgroup removed": {syscall.ENODEV, ErrLimitNotApplied},
- "a spawn failure": {syscall.ENOENT, syscall.ENOENT},
+ "clone3 refused": {syscall.EPERM, true, ErrLimitNotApplied},
+ "clone3 not understood": {syscall.EINVAL, true, ErrLimitNotApplied},
+ "not a cgroup": {syscall.EBADF, true, ErrLimitNotApplied},
+ "cgroup dead": {syscall.ENODEV, true, ErrLimitNotApplied},
+ "out of memory": {syscall.ENOMEM, true, ErrLimitNotApplied},
+ "out of processes": {syscall.EAGAIN, true, ErrLimitNotApplied},
+ "a missing binary": {syscall.ENOENT, true, syscall.ENOENT},
+ "a cgroup removed under the fd": {syscall.ENOENT, false, ErrLimitNotApplied},
+ "an unexecutable binary": {syscall.EACCES, true, syscall.EACCES},
+ "a cgroup that cannot be entered": {syscall.EACCES, false, ErrLimitNotApplied},
} {
t.Run(name, func(t *testing.T) {
m, parent := genManager(t, host)
+ g1 := filepath.Join(parent, "g1")
calls := fakeStart(t, func(cmd *exec.Cmd) error {
+ if tc.live {
+ // What cgroupfs shows in a live child: the kernel's own
+ // files, cgroup.procs empty and writable.
+ writeFixture(t, filepath.Join(g1, "cgroup.events"), "populated 0\nfrozen 0\n")
+ writeFixture(t, filepath.Join(g1, "cgroup.procs"), "")
+ }
if cmd.SysProcAttr != nil && cmd.SysProcAttr.UseCgroupFD {
return &os.PathError{Op: "fork/exec", Path: cmd.Path, Err: tc.fail}
}
@@ -283,8 +299,123 @@ func TestAGenerationStartIsNeverRetriedByPIDMove(t *testing.T) {
if m.fallback.Load() {
t.Error("a generation start latched the PID-move fallback")
}
- if _, err := os.Stat(filepath.Join(parent, "g1", "cgroup.procs")); !os.IsNotExist(err) {
- t.Errorf("a PID was moved into g1: %v", err)
+ if got, err := os.ReadFile(filepath.Join(g1, "cgroup.procs")); err == nil && len(got) != 0 {
+ t.Errorf("a PID was moved into g1: cgroup.procs %q", got)
+ } else if err != nil && !tc.live && !os.IsNotExist(err) {
+ t.Errorf("g1's cgroup.procs: %v", err)
+ }
+ })
+ }
+}
+
+// Fix round 1, finding 1: a generation start reads the PID-move latch once.
+// A flat start elsewhere in the agent may latch the fallback while this
+// generation's child is made and its limits written; the generation still
+// starts the one way it decided on, placed by clone3, never started in the
+// agent's cgroup and moved by PID. The latch is set from applyLimit's
+// summary log line, which comes after the old first read and before the
+// old second one.
+func TestAGenerationStartReadsTheLatchOnce(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ latched := false
+ m.logf = func(format string, args ...any) {
+ t.Logf(format, args...)
+ if !latched && strings.HasPrefix(format, "cgroup: %s: memory.max=") {
+ latched = true
+ m.fallback.Store(true) // a concurrent flat start's shouldLatch
+ }
+ }
+ builds := 0
+ calls := fakeStart(t, func(*exec.Cmd) error { return nil })
+ cmd, err := m.StartChild(host, GenChild(1), TierDefault, TierDefault, func() *exec.Cmd { builds++; return buildTrue() })
+ if !latched {
+ t.Fatal("the latch was never set during the start: applyLimit's summary line did not fire")
+ }
+ if err != nil || cmd == nil {
+ t.Fatalf("StartChild = %v, %v: want the clone3 start it decided on", cmd, err)
+ }
+ if builds != 1 || len(*calls) != 1 {
+ t.Fatalf("built %d, started %d times: want one of each", builds, len(*calls))
+ }
+ if attr := (*calls)[0].SysProcAttr; attr == nil || !attr.UseCgroupFD {
+ t.Fatal("the generation was started without its cgroup fd, to be moved by PID")
+ }
+ if _, err := os.Stat(filepath.Join(parent, "g1", "cgroup.procs")); !os.IsNotExist(err) {
+ t.Errorf("a PID was moved into g1: %v", err)
+ }
+}
+
+// placementLive is the ENOENT/EACCES probe: a child that is still a live
+// cgroup with a writable cgroup.procs puts a failed start on the command;
+// one removed, one that is no cgroup, or one whose cgroup.procs cannot be
+// written puts it on the placement. The probe writes nothing.
+func TestPlacementLiveProbesTheChild(t *testing.T) {
+ live := func(t *testing.T) string {
+ dir := t.TempDir()
+ writeFixture(t, filepath.Join(dir, "cgroup.events"), "populated 0\nfrozen 0\n")
+ writeFixture(t, filepath.Join(dir, "cgroup.procs"), "")
+ return dir
+ }
+ t.Run("live and writable", func(t *testing.T) {
+ dir := live(t)
+ if err := placementLive(dir); err != nil {
+ t.Fatalf("placementLive = %v, want nil", err)
+ }
+ if got := readFixture(t, filepath.Join(dir, "cgroup.procs")); got != "" {
+ t.Errorf("the probe wrote cgroup.procs: %q", got)
+ }
+ for _, e := range []error{syscall.ENOENT, syscall.EACCES} {
+ if placementFailed(&os.PathError{Op: "fork/exec", Path: "/bin/x", Err: e}, dir) {
+ t.Errorf("%v into a live child read as the placement's", e)
+ }
+ }
+ })
+ gone := map[string]func(t *testing.T) string{
+ "removed": func(t *testing.T) string {
+ dir := live(t)
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ },
+ "no cgroup.events": func(t *testing.T) string {
+ dir := live(t)
+ if err := os.Remove(filepath.Join(dir, "cgroup.events")); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ },
+ "no cgroup.procs": func(t *testing.T) string {
+ dir := live(t)
+ if err := os.Remove(filepath.Join(dir, "cgroup.procs")); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ },
+ }
+ if os.Geteuid() != 0 { // root opens a read-only file for writing
+ gone["cgroup.procs not writable"] = func(t *testing.T) string {
+ dir := live(t)
+ if err := os.Chmod(filepath.Join(dir, "cgroup.procs"), 0o444); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ }
+ }
+ for name, mk := range gone {
+ t.Run(name, func(t *testing.T) {
+ dir := mk(t)
+ if err := placementLive(dir); err == nil {
+ t.Fatal("placementLive = nil, want the probe to fail")
+ }
+ for _, e := range []error{syscall.ENOENT, syscall.EACCES} {
+ if !placementFailed(&os.PathError{Op: "fork/exec", Path: "/bin/x", Err: e}, dir) {
+ t.Errorf("%v into a gone child read as the command's", e)
+ }
+ }
+ if placementFailed(&os.PathError{Op: "fork/exec", Path: "/bin/x", Err: syscall.ENOEXEC}, dir) {
+ t.Error("ENOEXEC read as the placement's")
}
})
}
diff --git a/internal/cgroup/cgroup_other.go b/internal/cgroup/cgroup_other.go
index 2317b0640..3a754cb04 100644
--- a/internal/cgroup/cgroup_other.go
+++ b/internal/cgroup/cgroup_other.go
@@ -13,6 +13,6 @@ func setInto(cmd *exec.Cmd, fd int) {}
func shouldLatch(error) bool { return false }
-func placementFailed(error) bool { return false }
+func placementFailed(error, string) bool { return false }
func clearInto(*exec.Cmd) {}
diff --git a/internal/cgroup/gen.go b/internal/cgroup/gen.go
index 1d1c81a76..91b9dd172 100644
--- a/internal/cgroup/gen.go
+++ b/internal/cgroup/gen.go
@@ -139,10 +139,11 @@ var ErrLegacyParentPopulated = errors.New("cgroup: the host's parent cgroup hold
// memory), the parent's backstop could not be written, the generation's own
// child could not be made or one of its four limit writes failed, or the
// clone3 CLONE_INTO_CGROUP placement is unavailable, so the process could
-// only be started outside its cgroup and moved after. The flat start
-// (StartGated) stays best-effort and never returns it. Nothing is exec'd,
-// and it is no fault of the build: like ErrLegacyParentPopulated, the
-// condition is the box's (Task 7, zdd flag readiness round 2).
+// only be started outside its cgroup and moved after, or it failed on the
+// placement or the box rather than the command (placementFailed). The flat
+// start (StartGated) stays best-effort and never returns it. Nothing is
+// exec'd, and it is no fault of the build: like ErrLegacyParentPopulated,
+// the condition is the box's (Task 7, zdd flag readiness round 2).
var ErrLimitNotApplied = errors.New("cgroup: a generation's limits could not be applied")
// delegatesMemory reports whether dir hands the memory controller to its
diff --git a/internal/cgroup/gen_test.go b/internal/cgroup/gen_test.go
index e8131e759..90669225e 100644
--- a/internal/cgroup/gen_test.go
+++ b/internal/cgroup/gen_test.go
@@ -657,19 +657,62 @@ func TestAGenerationWhoseLimitsCannotBeWrittenIsRefused(t *testing.T) {
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
- if err := m.applyLimit(host, dir, TierDefault); err != nil {
- t.Fatalf("applyLimit with every write landing = %v", err)
+ for _, required := range []bool{false, true} {
+ if err := m.applyLimit(host, dir, TierDefault, required); err != nil {
+ t.Fatalf("applyLimit(required %v) with every write landing = %v", required, err)
+ }
+ if err := m.applyLimit(host, dir, Limit{}, required); err != nil {
+ t.Fatalf("applyLimit(required %v) with no limit = %v", required, err)
+ }
+ }
+}
+
+// Fix round 1, finding 7: a generation whose memory.max cannot be written
+// is refused, so the journal must not say it "runs uncapped". The required
+// path logs one refusal line naming the file; the flat start's lines are
+// unchanged.
+func TestARefusedGenerationsLimitFailureLogsTheRefusalOnly(t *testing.T) {
+ const host = "h.example.com"
+ m, parent, lines := legacyParent(t, host, "")
+ if err := os.MkdirAll(filepath.Join(parent, "g1", "memory.max"), 0o755); err != nil { // WriteFile: EISDIR
+ t.Fatal(err)
+ }
+ refusedChild(t, m, host, GenChild(1), TierDefault, ErrLimitNotApplied)
+ var refusals int
+ for _, l := range *lines {
+ if strings.Contains(l, "uncapped") || strings.Contains(l, ": memory.max=") { // the parent's own "parent memory.max=" line is true
+ t.Errorf("a refused generation logged %q", l)
+ }
+ if strings.Contains(l, "g1: memory.max:") && strings.Contains(l, "generation start refused") {
+ refusals++
+ }
+ }
+ if refusals != 1 {
+ t.Errorf("log lines = %q, want one refusal naming g1's memory.max", *lines)
+ }
+ // The flat start keeps its line.
+ *lines = nil
+ flat := m.hostDir("flat.example.com")
+ if err := os.MkdirAll(filepath.Join(flat, "memory.max"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.Start("flat.example.com", TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
}
- if err := m.applyLimit(host, dir, Limit{}); err != nil {
- t.Fatalf("applyLimit with no limit = %v", err)
+ if !slices.ContainsFunc(*lines, func(l string) bool {
+ return strings.Contains(l, "memory.max:") && strings.Contains(l, "child runs uncapped")
+ }) {
+ t.Errorf("flat log lines = %q, want the unchanged \"child runs uncapped\" line", *lines)
}
}
// G: with the clone3 CLONE_INTO_CGROUP placement unavailable, a generation
// could only be started and then moved by PID: running, for that window,
// in the agent's cgroup with no limit, and for good if the move fails.
-// A latched fallback refuses before anything is created or built. The flat
-// start keeps the PID-move fallback.
+// A latched fallback refuses before the child is created or build() runs
+// (ensureParent has already run). The flat start keeps the PID-move
+// fallback.
func TestAGenerationIsNeverStartedThenMovedWhenTheFallbackIsLatched(t *testing.T) {
const host = "h.example.com"
m, parent := genManager(t, host)