carlos / platform Public

zdd plan 2 task 1: flat generation layout: parent cgroup, g<n> children, parent limit

- GenChild(n) returns "g<n>", RoleChild(r) returns "role-<r>"
- isChildName validates generation and role child names
- OverlapLimit sums two tiers for parent while both generations run
- ValidParentLimit accepts one tier or the sum of two
- StartChild creates a child cgroup with its own tier, or a role-child under parent's limit
- SetParentLimit writes the parent's backstop limit with memory.oom.group=0
- RemoveChild removes an empty child cgroup (nil-safe and name-guarded)
- delegatedControllers and enableChildren delegate controllers to the parent's children
- ensureParent initializes a generation parent: delegates on cold wake, leaves it alone if live
- parentPopulated reads cgroup.events to detect if the parent has had any generations
- sweepStale now removes child cgroups first (rmdir refuses non-empty parent dirs)
- Start refactored through startIn to support both host and child cgroups
- startAndMove refactored through startAndMoveIn for explicit directory argument

Dormant: GenContainment flag off, all generation layout code inactive outside tests.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b6fc39369d4dd4c6b7f86d5101952535e1ca1469 parent db8c3f0
3 files changed, +513 −15
  • internal/cgroup/cgroup.go +36 −15
  • internal/cgroup/gen.go +200 −0
  • internal/cgroup/gen_test.go +277 −0
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go
index 6194feb67..5c2a74457 100644
--- a/internal/cgroup/cgroup.go
+++ b/internal/cgroup/cgroup.go
@@ -244,7 +244,20 @@ func (m *Manager) sweepStale() {
if !e.IsDir() || !IsExecName(e.Name()) {
continue
}
- if err := os.Remove(filepath.Join(m.root, e.Name())); err != nil {
+ dir := filepath.Join(m.root, e.Name())
+ // A generation parent's children go first: rmdir refuses a cgroup
+ // that has child cgroups, empty or not (zero-downtime spec §7.3).
+ if kids, err := os.ReadDir(dir); err == nil {
+ for _, k := range kids {
+ if !k.IsDir() || !isChildName(k.Name()) {
+ continue
+ }
+ if err := os.Remove(filepath.Join(dir, k.Name())); err != nil {
+ m.logf("cgroup: stale %s/%s not removed: %v", e.Name(), k.Name(), err)
+ }
+ }
+ }
+ if err := os.Remove(dir); err != nil {
m.logf("cgroup: stale %s not removed: %v", e.Name(), err)
}
}
@@ -284,7 +297,13 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec
cmd := build()
return cmd, startCmd(cmd)
}
- dir := m.hostDir(host)
+ return m.startIn(host, m.hostDir(host), limit, build)
+}
+
+// startIn is Start against an explicit cgroup directory: the host's own
+// (Start) or one of its children (StartChild). The clone3 attempt, the
+// fallback and its latch are Start's, unchanged.
+func (m *Manager) startIn(host, dir string, limit Limit, build func() *exec.Cmd) (*exec.Cmd, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err)
cmd := build()
@@ -295,7 +314,7 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec
f, err := os.Open(dir)
if err != nil {
m.logf("cgroup: %s: open cgroup dir: %v — using PID-move fallback for this spawn", host, err)
- return m.startAndMove(host, build)
+ return m.startAndMoveIn(host, dir, build)
}
cmd := build()
setInto(cmd, int(f.Fd()))
@@ -306,9 +325,9 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec
return cmd, nil
}
firstErr := err
- retried, retryErr := m.startAndMove(host, build)
+ retried, retryErr := m.startAndMoveIn(host, dir, build)
if retryErr != nil {
- // startAndMove already dropped the ghost host dir on its own
+ // startAndMoveIn already dropped the ghost dir on its own
// failure; join both errors so the operator can see a clone3
// attempt happened at all, not just that the retry failed.
return nil, errors.Join(firstErr, retryErr)
@@ -321,7 +340,7 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec
}
return retried, nil
}
- return m.startAndMove(host, build)
+ return m.startAndMoveIn(host, dir, build)
}
// applyLimit writes limit into dir. Best-effort in the package's usual
@@ -365,27 +384,29 @@ func (m *Manager) applyLimit(host, dir string, limit Limit) {
m.logf("cgroup: %s: memory.max=%dM memory.high=%dM", host, limit.MemoryMax>>20, HighOf(limit.MemoryMax)>>20)
}
-// startAndMove is the fallback spawn: plain start, then write the child's
-// PID into the host cgroup. A failed move is logged and tolerated — the
-// child runs (unaccounted), which beats not running.
func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd, error) {
+ return m.startAndMoveIn(host, m.hostDir(host), build)
+}
+
+// startAndMoveIn is startAndMove into an explicit cgroup directory.
+func (m *Manager) startAndMoveIn(host, dir string, build func() *exec.Cmd) (*exec.Cmd, error) {
cmd := build()
if err := startCmd(cmd); err != nil {
- // The spawn never happened: drop the now-ownerless host dir so a
- // failed wake doesn't leave the sampler reporting permanent zeros
- // for a host with nothing running. rmdir-safe — os.Remove refuses a
+ // The spawn never happened: drop the now-ownerless dir so a failed
+ // wake doesn't leave the sampler reporting permanent zeros for a
+ // host with nothing running. rmdir-safe: os.Remove refuses a
// non-empty dir, so this can never take a live sibling's dir.
- _ = os.Remove(m.hostDir(host))
+ _ = os.Remove(dir)
return nil, err
}
if cmd.Process == nil {
// A broken startCmd seam reporting success with no process: this
// package must never panic the agent over it. Log and skip the
- // move — the child, if any, simply stays unaccounted.
+ // move; the child, if any, simply stays unaccounted.
m.logf("cgroup: %s: start reported success with no process — skipping pid move", host)
return cmd, nil
}
- procs := filepath.Join(m.hostDir(host), "cgroup.procs")
+ procs := filepath.Join(dir, "cgroup.procs")
if err := os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err)
}
diff --git a/internal/cgroup/gen.go b/internal/cgroup/gen.go
new file mode 100644
index 000000000..997357278
--- /dev/null
+++ b/internal/cgroup/gen.go
@@ -0,0 +1,200 @@
+package cgroup
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/controlname"
+)
+
+// The generation layout (zero-downtime deploys spec §7.3). A host's
+// carlos-exec@<host> becomes a PARENT that holds no processes. Each
+// instance spawn lands in its own generation child g<n>, which carries the
+// tier's full limits and memory.oom.group=1, so a successor that overruns
+// dies alone and the incumbent is untouched. The legacy co-spawned sidecar
+// and the seed land in role- children with no limit of their own. The
+// parent carries the tier with memory.oom.group=0: a backstop for the
+// instance as a whole, raised to OverlapLimit while two generations run.
+// Nothing here runs unless the exec backend's GenContainment is set
+// (plan 5's CARLOS_SWAP).
+
+// GenChild is generation n's child cgroup: "g<n>".
+func GenChild(n uint64) string { return "g" + strconv.FormatUint(n, 10) }
+
+// RoleChild is the child cgroup a non-generation spawn of a host lands in
+// under the generation layout: "role-<role>", the name PrepareRoleChild
+// pins.
+func RoleChild(role string) string { return "role-" + role }
+
+// isChildName reports whether name is one of the layout's child names:
+// g<n> with n canonical and above zero, or role-<label>. Only these are
+// ever created, removed or swept.
+func isChildName(name string) bool {
+ if rest, ok := strings.CutPrefix(name, "role-"); ok {
+ return controlname.ValidLabel(rest)
+ }
+ rest, ok := strings.CutPrefix(name, "g")
+ if !ok || rest == "" {
+ return false
+ }
+ n, err := strconv.ParseUint(rest, 10, 64)
+ return err == nil && n > 0 && strconv.FormatUint(n, 10) == rest
+}
+
+// isGenName reports whether name is a generation child's name.
+func isGenName(name string) bool { return strings.HasPrefix(name, "g") && isChildName(name) }
+
+// OverlapLimit is the parent's limit while two generations overlap: the two
+// tiers summed. With equal tiers that is the spec's "twice the tier". When
+// a release changes tier it still has room for both (plan 2, D3).
+func OverlapLimit(incumbent, successor Limit) Limit {
+ return Limit{MemoryMax: incumbent.MemoryMax + successor.MemoryMax}
+}
+
+// ValidParentLimit reports whether l is a limit a generation parent may
+// carry: one tier, or the sum of two. Strict containment and the root
+// control service accept nothing else.
+func ValidParentLimit(l Limit) bool {
+ tiers := []int64{TierDefault.MemoryMax, TierChildren.MemoryMax}
+ for _, a := range tiers {
+ if l.MemoryMax == a {
+ return true
+ }
+ for _, b := range tiers {
+ if l.MemoryMax == a+b {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// delegatedControllers is the subtree_control write that hands a parent's
+// children every controller of cpu, memory, io and pids that the parent
+// itself has (its cgroup.controllers). A user manager delegates no io, for
+// one, and a write naming a controller the parent lacks fails as a whole.
+func delegatedControllers(have string) string {
+ fields := strings.Fields(have)
+ var want []string
+ for _, c := range []string{"cpu", "memory", "io", "pids"} {
+ if slices.Contains(fields, c) {
+ want = append(want, "+"+c)
+ }
+ }
+ return strings.Join(want, " ")
+}
+
+// enableChildren delegates dir's controllers to its children. Memory must
+// be among them, or a generation's limits could not exist.
+func enableChildren(dir string) error {
+ have, err := os.ReadFile(filepath.Join(dir, "cgroup.controllers"))
+ if err != nil {
+ return err
+ }
+ want := delegatedControllers(string(have))
+ if !strings.Contains(want, "+memory") {
+ return errors.New("no memory controller to delegate")
+ }
+ return os.WriteFile(filepath.Join(dir, "cgroup.subtree_control"), []byte(want), 0o644)
+}
+
+// parentPopulated reads dir's cgroup.events. An absent file means nothing
+// has ever run there: not populated. A file that cannot be read counts as
+// populated, so a raised limit is never lowered on a guess.
+func parentPopulated(dir string) bool {
+ b, err := os.ReadFile(filepath.Join(dir, "cgroup.events"))
+ if errors.Is(err, os.ErrNotExist) {
+ return false
+ }
+ if err != nil {
+ return true
+ }
+ return !strings.Contains(string(b), "populated 0")
+}
+
+// ensureParent makes carlos-exec@<host> a generation parent. When nothing
+// is alive under it (a cold wake), it delegates the controllers and writes
+// the tier with memory.oom.group=0. With a live generation under it, the
+// parent is left as it is, because a swap raised it on purpose. Best-effort,
+// like the rest of the accounting path: a failure is one logged line and
+// children that run without their limits, never a refused spawn.
+func (m *Manager) ensureParent(host string, tier Limit) {
+ dir := m.hostDir(host)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ m.logf("cgroup: %s: create parent cgroup: %v (children run unaccounted)", host, err)
+ return
+ }
+ if parentPopulated(dir) {
+ return
+ }
+ if err := enableChildren(dir); err != nil {
+ m.logf("cgroup: %s: delegate controllers to generations: %v (children run uncapped)", host, err)
+ return
+ }
+ if tier.MemoryMax > 0 {
+ if err := m.SetParentLimit(host, tier); err != nil {
+ m.logf("cgroup: %s: parent limit: %v", host, err)
+ }
+ }
+}
+
+// StartChild is Start for the generation layout: the child lands in
+// carlos-exec@<host>/<child>. own is the child's own limit: the tier for a
+// generation, zero for a role child, which then lives under the parent's
+// limit alone. parent is the tier the parent takes when this spawn finds
+// nothing alive under it. Nil-safe and best-effort like Start.
+func (m *Manager) StartChild(host, child string, parent, own Limit, build func() *exec.Cmd) (*exec.Cmd, error) {
+ if !isChildName(child) {
+ return nil, fmt.Errorf("cgroup: %s: %q is not a child cgroup name", host, child)
+ }
+ if m == nil {
+ cmd := build()
+ return cmd, startCmd(cmd)
+ }
+ m.ensureParent(host, parent)
+ return m.startIn(host, filepath.Join(m.hostDir(host), child), own, build)
+}
+
+// SetParentLimit writes l onto host's parent: memory.max, memory.high
+// (HighOf), memory.swap.max, and memory.oom.group=0 (spec §7.3). The swap
+// raises it to OverlapLimit before the successor spawns and restores the
+// tier once the retiring child is empty. Unlike applyLimit it reports the
+// first failure, because a raise that did not land must stop the overlap.
+// A nil manager has no parent to set and answers nil.
+func (m *Manager) SetParentLimit(host string, l Limit) error {
+ if m == nil {
+ return nil
+ }
+ if l.MemoryMax <= 0 {
+ return fmt.Errorf("cgroup: %s: parent limit %d is not a limit", host, l.MemoryMax)
+ }
+ dir := m.hostDir(host)
+ for _, w := range []struct{ name, value string }{
+ {"memory.max", strconv.FormatInt(l.MemoryMax, 10)},
+ {"memory.high", strconv.FormatInt(HighOf(l.MemoryMax), 10)},
+ {"memory.swap.max", strconv.FormatInt(l.MemoryMax, 10)},
+ {"memory.oom.group", "0"},
+ } {
+ if err := os.WriteFile(filepath.Join(dir, w.name), []byte(w.value), 0o644); err != nil {
+ return fmt.Errorf("cgroup: %s: parent %s: %w", host, w.name, err)
+ }
+ }
+ m.logf("cgroup: %s: parent memory.max=%dM", host, l.MemoryMax>>20)
+ return nil
+}
+
+// RemoveChild removes host's child cgroup once it is empty. Nil-safe and
+// rmdir-tolerant like CleanupHost, and it touches only the layout's own
+// child names. The parent is CleanupHost's to remove.
+func (m *Manager) RemoveChild(host, child string) {
+ if m == nil || !isChildName(child) {
+ return
+ }
+ _ = os.Remove(filepath.Join(m.hostDir(host), child))
+}
diff --git a/internal/cgroup/gen_test.go b/internal/cgroup/gen_test.go
new file mode 100644
index 000000000..367938eab
--- /dev/null
+++ b/internal/cgroup/gen_test.go
@@ -0,0 +1,277 @@
+package cgroup
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+)
+
+// quietOOM points the oom_score_adj write at a temp dir, so a fake start
+// that reports our own PID never re-scores the test process.
+func quietOOM(t *testing.T) {
+ t.Helper()
+ dir := t.TempDir()
+ orig := oomScoreAdjPath
+ oomScoreAdjPath = func(pid int) string { return filepath.Join(dir, strconv.Itoa(pid)) }
+ t.Cleanup(func() { oomScoreAdjPath = orig })
+}
+
+func writeFixture(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func readFixture(t *testing.T, path string) string {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ return string(b)
+}
+
+// genManager is a delegated fixture root whose host dir already lists the
+// four controllers, as a real child of the delegated root would.
+func genManager(t *testing.T, host string) (*Manager, string) {
+ t.Helper()
+ quietOOM(t)
+ m, err := SetupAt(newDelegatedRoot(t), t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parent := m.hostDir(host)
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ writeFixture(t, filepath.Join(parent, "cgroup.controllers"), "cpu io memory pids\n")
+ return m, parent
+}
+
+func TestGenAndRoleChildNames(t *testing.T) {
+ if GenChild(7) != "g7" || RoleChild("sidecar") != "role-sidecar" {
+ t.Fatalf("GenChild(7) = %q, RoleChild(sidecar) = %q", GenChild(7), RoleChild("sidecar"))
+ }
+ for name, want := range map[string]bool{
+ "g7": true, "g18446744073709551615": true, "role-seed": true, "role-sidecar": true,
+ "g0": false, "g07": false, "g": false, "gx": false, "g-1": false,
+ "role-": false, "role-a/b": false, "role-..": false, "child": false,
+ "carlos-exec@h.example.com": false, "../g7": false,
+ } {
+ if got := isChildName(name); got != want {
+ t.Errorf("isChildName(%q) = %v, want %v", name, got, want)
+ }
+ }
+}
+
+func TestOverlapAndValidParentLimits(t *testing.T) {
+ if got := OverlapLimit(TierDefault, TierChildren); got.MemoryMax != 1536<<20 {
+ t.Errorf("OverlapLimit(512M, 1G) = %d", got.MemoryMax)
+ }
+ for max, want := range map[int64]bool{
+ 512 << 20: true, 1 << 30: true, 1536 << 20: true, 2 << 30: true,
+ 0: false, 64 << 20: false, 3 << 30: false,
+ } {
+ if got := ValidParentLimit(Limit{MemoryMax: max}); got != want {
+ t.Errorf("ValidParentLimit(%d) = %v, want %v", max, got, want)
+ }
+ }
+}
+
+func TestStartChildPutsTheTierOnTheGenerationAndTheParent(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.StartChild(host, GenChild(1), TierDefault, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ high := strconv.FormatInt(HighOf(TierDefault.MemoryMax), 10)
+ child := filepath.Join(parent, "g1")
+ for file, want := range map[string]string{
+ filepath.Join(child, "memory.max"): "536870912",
+ filepath.Join(child, "memory.high"): high,
+ filepath.Join(child, "memory.swap.max"): "536870912",
+ filepath.Join(child, "memory.oom.group"): "1",
+ filepath.Join(parent, "memory.max"): "536870912",
+ filepath.Join(parent, "memory.high"): high,
+ filepath.Join(parent, "memory.swap.max"): "536870912",
+ filepath.Join(parent, "memory.oom.group"): "0",
+ filepath.Join(parent, "cgroup.subtree_control"): "+cpu +memory +io +pids",
+ } {
+ if got := readFixture(t, file); got != want {
+ t.Errorf("%s = %q, want %q", file, got, want)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(parent, "cgroup.procs")); !os.IsNotExist(err) {
+ t.Errorf("the parent must hold no process, but its cgroup.procs was written (%v)", err)
+ }
+}
+
+func TestStartChildRoleChildCarriesNoLimitOfItsOwn(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.StartChild(host, RoleChild("sidecar"), TierDefault, Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(parent, "role-sidecar", "memory.max")); !os.IsNotExist(err) {
+ t.Errorf("a role child got a limit of its own: %v", err)
+ }
+ if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "536870912" {
+ t.Errorf("parent memory.max = %q, want the tier", got)
+ }
+}
+
+// A successor spawns while the incumbent lives: the swap has raised the
+// parent on purpose, and the spawn must not lower it.
+func TestStartChildLeavesALiveParentAlone(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 1\nfrozen 0\n")
+ writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824")
+ if _, err := m.StartChild(host, GenChild(2), TierDefault, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "1073741824" {
+ t.Errorf("a successor's spawn changed the raised parent to %q", got)
+ }
+}
+
+// Review focus 1: an agent killed between the raise and the restore leaves
+// the parent at two tiers. The next cold wake puts it back.
+func TestStartChildResetsAStaleRaisedParentOnAColdWake(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 0\nfrozen 0\n")
+ writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824")
+ if _, err := m.StartChild(host, GenChild(3), TierDefault, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "536870912" {
+ t.Errorf("cold wake left the parent at %q, want the tier", got)
+ }
+}
+
+func TestStartChildRefusesAForeignChildName(t *testing.T) {
+ m, _ := genManager(t, "h.example.com")
+ calls := fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.StartChild("h.example.com", "../carlos-exec@other.example.com", TierDefault, TierDefault, buildTrue); err == nil {
+ t.Fatal("a child name outside the layout was accepted")
+ }
+ if len(*calls) != 0 {
+ t.Fatalf("spawned %d times for a refused child name", len(*calls))
+ }
+}
+
+func TestSetParentLimitWritesTheBackstopAndReportsFailure(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ if err := m.SetParentLimit(host, OverlapLimit(TierDefault, TierDefault)); err != nil {
+ t.Fatal(err)
+ }
+ for file, want := range map[string]string{
+ "memory.max": "1073741824",
+ "memory.high": strconv.FormatInt(HighOf(1<<30), 10),
+ "memory.swap.max": "1073741824",
+ "memory.oom.group": "0",
+ } {
+ if got := readFixture(t, filepath.Join(parent, file)); got != want {
+ t.Errorf("%s = %q, want %q", file, got, want)
+ }
+ }
+ // A raise that did not land must say so: the swap stops on it.
+ if err := os.Remove(filepath.Join(parent, "memory.max")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(parent, "memory.max"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.SetParentLimit(host, TierDefault); err == nil {
+ t.Fatal("a limit write that failed reported success")
+ }
+ var nilM *Manager
+ if err := nilM.SetParentLimit(host, TierDefault); err != nil {
+ t.Fatalf("nil manager: %v, want nil (no delegation, nothing to set)", err)
+ }
+}
+
+// Review focus 2: rmdir refuses a cgroup with child cgroups, empty or not.
+func TestSweepRemovesEmptyGenerationChildrenThenTheParent(t *testing.T) {
+ root := newDelegatedRoot(t)
+ stale := filepath.Join(root, ExecName("old.example.com"))
+ for _, d := range []string{"g3", "role-seed"} {
+ if err := os.MkdirAll(filepath.Join(stale, d), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ live := filepath.Join(root, ExecName("live.example.com"))
+ if err := os.MkdirAll(filepath.Join(live, "g4"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // A file stands in for a member: rmdir refuses the directory.
+ writeFixture(t, filepath.Join(live, "g4", "cgroup.procs"), "123\n")
+ if _, err := SetupAt(root, t.Logf); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(stale); !os.IsNotExist(err) {
+ t.Errorf("stale generation parent survived the sweep: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(live, "g4")); err != nil {
+ t.Errorf("a populated generation child must be left alone: %v", err)
+ }
+}
+
+func TestRemoveChildRemovesOnlyItsOwnNames(t *testing.T) {
+ const host = "h.example.com"
+ m, parent := genManager(t, host)
+ for _, d := range []string{"g5", "keep"} {
+ if err := os.Mkdir(filepath.Join(parent, d), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ m.RemoveChild(host, "g5")
+ m.RemoveChild(host, "keep")
+ m.RemoveChild(host, "../"+ExecName("other.example.com"))
+ if _, err := os.Stat(filepath.Join(parent, "g5")); !os.IsNotExist(err) {
+ t.Errorf("empty g5 survived: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(parent, "keep")); err != nil {
+ t.Errorf("a foreign child was removed: %v", err)
+ }
+ var nilM *Manager
+ nilM.RemoveChild(host, "g5") // must not panic
+}
+
+// Flag off: Start keeps today's single-level layout.
+func TestStartKeepsTheSingleLevelLayout(t *testing.T) {
+ quietOOM(t)
+ m, err := SetupAt(newDelegatedRoot(t), t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.Start("h.example.com", TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ dir := m.hostDir("h.example.com")
+ if got := readFixture(t, filepath.Join(dir, "memory.oom.group")); got != "1" {
+ t.Errorf("host cgroup memory.oom.group = %q, want 1", got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "cgroup.subtree_control")); !os.IsNotExist(err) {
+ t.Errorf("flag-off Start delegated controllers: %v", err)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ t.Errorf("flag-off Start made a child cgroup %s", e.Name())
+ }
+ }
+}