carlos / platform Public

zdd-readiness: a cgroup that took cgroup.kill is recreated before anything respawns into it

amadan #272: on the dev box a clone3(CLONE_INTO_CGROUP) into a cgroup that took cgroup.kill is SIGKILLed at once. Every kill (Cleanup and strict) marks the cgroup by identity before writing cgroup.kill; the next Start, StartChild or PrepareStrict into that path removes the killed tree under a new per-host start lock and creates it as a first start does (same limits, same parent). A removal that fails, or a cgroup a strict handle still pins, refuses the start with ErrKilledCgroupKept. A killed strict child is never re-pinned. The kernel-gated TestClaimContainmentCleanupRespawn now also runs the real KillHost and KillSidecar respawn sequences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c76a1d44c9d289fe76db370f37f829892e584161 parent 2c5adbe
11 files changed, +835 −9
  • internal/activator/claimspawn_kernel_linux_test.go +132 −1
  • internal/activator/genbackend.go +9 −0
  • internal/cgroup/cgroup.go +38 −1
  • internal/cgroup/cleanup_linux.go +4 −0
  • internal/cgroup/gen.go +7 −1
  • internal/cgroup/recreate_linux.go +225 −0
  • internal/cgroup/recreate_linux_test.go +374 −0
  • internal/cgroup/recreate_other.go +11 −0
  • internal/cgroup/strict_gen_linux.go +9 −2
  • internal/cgroup/strict_gen_linux_test.go +13 −4
  • internal/cgroup/strict_linux.go +13 −0
diff --git a/internal/activator/claimspawn_kernel_linux_test.go b/internal/activator/claimspawn_kernel_linux_test.go
index 6aedaad67..9041c16fa 100644
--- a/internal/activator/claimspawn_kernel_linux_test.go
+++ b/internal/activator/claimspawn_kernel_linux_test.go
@@ -5,12 +5,19 @@ package activator
import (
"context"
"errors"
+ "fmt"
"os"
"os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
"testing"
"time"
"github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
)
func TestClaimSpawnKernelChild(t *testing.T) {
@@ -20,6 +27,14 @@ func TestClaimSpawnKernelChild(t *testing.T) {
time.Sleep(time.Minute)
}
+// TestClaimContainmentCleanupRespawn kills a cgroup and spawns into the
+// same path again, on a real kernel. On the dev box (amadan #272: arm64,
+// kernel 6.12.105, systemd 257) a clone3(CLONE_INTO_CGROUP) into a cgroup
+// that took cgroup.kill is SIGKILLed at once, so each respawn below must
+// land in a fresh cgroup and stay alive. Three sequences: the strict
+// primitive (PrepareStrict, then a cleanup handle's kill), a worker's
+// KillHost then its respawn into carlos-exec@<host>, and the legacy
+// sidecar's KillSidecar then its respawn into role-sidecar.
func TestClaimContainmentCleanupRespawn(t *testing.T) {
if os.Getenv("CARLOS_CLAIM_SPAWN_KERNEL") != "1" {
t.Skip("requires a disposable delegated user service")
@@ -28,7 +43,6 @@ func TestClaimContainmentCleanupRespawn(t *testing.T) {
if manager == nil {
t.Fatal("missing delegated cgroup")
}
- const host = "claimspawn.example"
dir := t.TempDir()
build := func() *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=^TestClaimSpawnKernelChild$", "-test.count=1")
@@ -36,6 +50,13 @@ func TestClaimContainmentCleanupRespawn(t *testing.T) {
cmd.Dir = dir
return cmd
}
+ t.Run("strict primitive", func(t *testing.T) { claimStrictRespawn(t, manager, build) })
+ t.Run("KillHost then respawn", func(t *testing.T) { killHostRespawn(t, manager, build) })
+ t.Run("KillSidecar then respawn", func(t *testing.T) { killSidecarRespawn(t, manager, build) })
+}
+
+func claimStrictRespawn(t *testing.T, manager *cgroup.Manager, build func() *exec.Cmd) {
+ const host = "claimspawn.example"
for attempt := range 2 {
// This tests the cgroup primitive with a synthetic test child. It
// grants no claim launch authority and is not a tenant-isolation test.
@@ -84,3 +105,113 @@ func TestClaimContainmentCleanupRespawn(t *testing.T) {
}
}
}
+
+// spawnStaysUp fails the test if cmd exits within a moment of its start:
+// the #272 symptom is a child SIGKILLed as it lands. It returns the
+// channel cmd.Wait's result arrives on.
+func spawnStaysUp(t *testing.T, label string, cmd *exec.Cmd) <-chan error {
+ t.Helper()
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+ select {
+ case err := <-done:
+ t.Fatalf("%s: the child died as it landed: %v", label, err)
+ case <-time.After(500 * time.Millisecond):
+ }
+ return done
+}
+
+// reaped waits for a killed child's exit.
+func reaped(t *testing.T, label string, done <-chan error) {
+ t.Helper()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatalf("%s: the killed child was not reaped", label)
+ }
+}
+
+func cgroupInode(t *testing.T, path string) uint64 {
+ t.Helper()
+ var st syscall.Stat_t
+ if err := syscall.Lstat(path, &st); err != nil {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+ return st.Ino
+}
+
+// killHostRespawn is a worker's sequence: spawned through spawnInCgroup into
+// carlos-exec@<host>, proven terminated by the real ExecBackend.KillHost,
+// then respawned into the same path. Each respawn gets a fresh cgroup with
+// the tier written again, and stays up.
+func killHostRespawn(t *testing.T, manager *cgroup.Manager, build func() *exec.Cmd) {
+ const host = "killhost.claimspawn.example"
+ b := &ExecBackend{Cgroups: manager}
+ rt := registry.Route{Host: host}
+ dir := filepath.Join(manager.Root(), cgroup.ExecName(host))
+ var last uint64
+ for attempt := range 3 {
+ cmd, settle, err := b.spawnInCgroup(rt, build)
+ if err != nil {
+ t.Fatalf("spawn %d: %v", attempt, err)
+ }
+ done := spawnStaysUp(t, fmt.Sprintf("spawn %d", attempt), cmd)
+ ino := cgroupInode(t, dir)
+ if attempt > 0 && ino == last {
+ t.Fatalf("spawn %d reused the killed host cgroup", attempt)
+ }
+ last = ino
+ if got, err := os.ReadFile(filepath.Join(dir, "memory.max")); err != nil || strings.TrimSpace(string(got)) != strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10) {
+ t.Fatalf("spawn %d: memory.max = %q, %v; want the tier", attempt, got, err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
+ err = b.KillHost(ctx, rt)
+ cancel()
+ if err != nil {
+ t.Fatalf("KillHost %d: %v", attempt, err)
+ }
+ reaped(t, fmt.Sprintf("spawn %d", attempt), done)
+ settle()
+ }
+ b.cleanupHost(host)
+}
+
+// killSidecarRespawn is the legacy sidecar's sequence under containment: a
+// flat row's sidecar spawned into carlos-exec@<host>/role-sidecar, proven
+// gone by the real ExecBackend.KillSidecar, then respawned. Each respawn gets
+// a fresh role-sidecar under the same parent, and stays up.
+func killSidecarRespawn(t *testing.T, manager *cgroup.Manager, build func() *exec.Cmd) {
+ const host = "killsidecar.claimspawn.example"
+ b := &ExecBackend{Cgroups: manager, GenContainment: true, DataDir: t.TempDir(), OptDir: t.TempDir()}
+ rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.DefaultDBPath(b.DataDir, host),
+ Socket: filepath.Join(provision.SocketDir, host+".sock")}
+ parent := filepath.Join(manager.Root(), cgroup.ExecName(host))
+ child := cgroup.RoleChild("sidecar")
+ var last, parentIno uint64
+ for attempt := range 3 {
+ cmd, _, err := b.spawnInCgroupAt(rt, child, false, build)
+ if err != nil {
+ t.Fatalf("spawn %d: %v", attempt, err)
+ }
+ done := spawnStaysUp(t, fmt.Sprintf("sidecar spawn %d", attempt), cmd)
+ ino := cgroupInode(t, filepath.Join(parent, child))
+ if attempt > 0 && ino == last {
+ t.Fatalf("sidecar spawn %d reused the killed role-sidecar", attempt)
+ }
+ if p := cgroupInode(t, parent); attempt > 0 && p != parentIno {
+ t.Fatalf("sidecar spawn %d replaced the parent", attempt)
+ } else {
+ parentIno = p
+ }
+ last = ino
+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
+ err = b.KillSidecar(ctx, rt)
+ cancel()
+ if err != nil {
+ t.Fatalf("KillSidecar %d: %v", attempt, err)
+ }
+ reaped(t, fmt.Sprintf("sidecar spawn %d", attempt), done)
+ }
+ manager.RemoveChild(host, child)
+ manager.CleanupHost(host)
+}
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index 7025d74bd..01c2fa852 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -396,6 +396,10 @@ func (b *SplitBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g
// to whatever SIGTERM left). Without containment the sidecar shares the
// host cgroup with the instance, and the terminal stop's evidence on that
// cgroup is the proof (errNoGenContainment, as KillGen answers).
+//
+// The role child is kept after the kill, and a clone3 into a cgroup that
+// took cgroup.kill is SIGKILLed at once (amadan #272), so the next sidecar
+// start (StartChildGated) removes it and makes it fresh first, or refuses.
func (b *ExecBackend) KillSidecar(ctx context.Context, rt registry.Route) error {
switch p := b.pathsFor(rt); p.layout {
case LayoutNested:
@@ -487,6 +491,11 @@ func (b *SplitBackend) GenPaths(rt registry.Route, n uint64) (Gen, bool) {
// through spawnInCgroup into carlos-exec@<host>, whatever the layout says,
// so a nested-looking worker is proven there too. An absent cgroup is
// empty. No cgroup delegation at all is an unproven kill.
+//
+// The host cgroup is kept after the kill, and a clone3 into a cgroup that
+// took cgroup.kill is SIGKILLed at once (amadan #272), so the worker's next
+// start (StartGated) removes it and makes it fresh first, or refuses. That
+// holds with the flag off too, where only Task 15's F6 path reaches here.
func (b *ExecBackend) KillHost(ctx context.Context, rt registry.Route) error {
if b.Cgroups == nil {
return cgroup.ErrNoDelegation
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go
index de40a60e8..85393b496 100644
--- a/internal/cgroup/cgroup.go
+++ b/internal/cgroup/cgroup.go
@@ -22,6 +22,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "sync"
"sync/atomic"
)
@@ -147,6 +148,28 @@ type Manager struct {
// started normally then moved by PID. A filter is static for the life of
// the process, so retrying the syscall on every spawn buys nothing.
fallback atomic.Bool
+ // locks holds each host's start lock (hostLock), made on first use.
+ locksMu sync.Mutex
+ locks map[string]*sync.Mutex
+}
+
+// hostLock is host's start lock. A start holds it while it checks for, and
+// removes, a cgroup of the host that took cgroup.kill (freshen, amadan
+// #272), so two starts of one host never both remove one. It is the
+// innermost lock: nothing is taken while it is held, and it is never held
+// across a before-start hook or a clone3.
+func (m *Manager) hostLock(host string) *sync.Mutex {
+ m.locksMu.Lock()
+ defer m.locksMu.Unlock()
+ if m.locks == nil {
+ m.locks = make(map[string]*sync.Mutex)
+ }
+ mu, ok := m.locks[host]
+ if !ok {
+ mu = &sync.Mutex{}
+ m.locks[host] = mu
+ }
+ return mu
}
// Root is the delegated subtree's path — the sampler's third scan root.
@@ -263,6 +286,12 @@ func (m *Manager) sweepStale() {
}
}
+// ErrKilledCgroupKept is a start refused because the cgroup it would spawn
+// into took cgroup.kill and could not be removed and made fresh first
+// (amadan #272). Spawning into it anyway would have the kernel SIGKILL the
+// child at once, so nothing is started.
+var ErrKilledCgroupKept = errors.New("cgroup: a cgroup that took cgroup.kill could not be made fresh before a spawn")
+
// startCmd is Start's spawn seam — a package var so the clone3 refusal
// path is testable without a seccomp filter in the loop.
var startCmd = func(cmd *exec.Cmd) error { return cmd.Start() }
@@ -304,10 +333,18 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec
// preparation as a failed start does (the cgroup dir this spawn would have
// used is removed if empty), and comes back as before's own error, never
// joined with an earlier start's. A nil before is Start exactly.
+//
+// A host cgroup that took cgroup.kill (KillHost keeps it) is removed and
+// made again before anything is spawned into it, and a start whose killed
+// cgroup will not go is refused with ErrKilledCgroupKept, never run
+// unaccounted (amadan #272, freshen).
func (m *Manager) StartGated(host string, limit Limit, build func() *exec.Cmd, before func() error) (*exec.Cmd, error) {
if m == nil {
return startPlain(build, before)
}
+ if err := m.freshen(host, m.hostDir(host)); err != nil {
+ return nil, err
+ }
return m.startIn(host, m.hostDir(host), limit, build, before)
}
@@ -508,5 +545,5 @@ func (m *Manager) CleanupHost(host string) {
if m == nil {
return
}
- _ = os.Remove(m.hostDir(host))
+ _ = removeEmptyDir(m.hostDir(host))
}
diff --git a/internal/cgroup/cleanup_linux.go b/internal/cgroup/cleanup_linux.go
index f11b99e96..64bc29806 100644
--- a/internal/cgroup/cleanup_linux.go
+++ b/internal/cgroup/cleanup_linux.go
@@ -313,6 +313,10 @@ func (c *Cleanup) KillAndEmpty(ctx context.Context) error {
if err := p.identityOK(); err != nil {
return err
}
+ // Marked first: the next start into this cgroup makes it fresh, since
+ // the kernel kills anything cloned into one that took cgroup.kill
+ // (amadan #272, recreate_linux.go).
+ markKilled(p.identity)
if err := strictWrite(p.kill, "1"); err != nil {
return err
}
diff --git a/internal/cgroup/gen.go b/internal/cgroup/gen.go
index faf8c3873..69379ccf4 100644
--- a/internal/cgroup/gen.go
+++ b/internal/cgroup/gen.go
@@ -208,6 +208,12 @@ func (m *Manager) StartChildGated(host, child string, parent, own Limit, build f
if m == nil {
return startPlain(build, before)
}
+ // A parent or child that took cgroup.kill is removed and made again
+ // first (amadan #272): KillSidecar keeps role-sidecar, and KillHost keeps
+ // the parent. ensureParent and startIn then build them as on a cold start.
+ if err := m.freshen(host, filepath.Join(m.hostDir(host), child)); err != nil {
+ return nil, err
+ }
if err := m.ensureParent(host, parent); err != nil {
return nil, err
}
@@ -249,5 +255,5 @@ func (m *Manager) RemoveChild(host, child string) {
if m == nil || !isChildName(child) {
return
}
- _ = os.Remove(filepath.Join(m.hostDir(host), child))
+ _ = removeEmptyDir(filepath.Join(m.hostDir(host), child))
}
diff --git a/internal/cgroup/recreate_linux.go b/internal/cgroup/recreate_linux.go
new file mode 100644
index 000000000..f7c3c2480
--- /dev/null
+++ b/internal/cgroup/recreate_linux.go
@@ -0,0 +1,225 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "golang.org/x/sys/unix"
+)
+
+// A cgroup that took cgroup.kill is never spawned into again (amadan #272).
+// Found on the dev box (arm64, kernel 6.12.105, systemd 257): once
+// cgroup.kill has been written to a cgroup, every later
+// clone3(CLONE_INTO_CGROUP) into that same cgroup is SIGKILLed at once,
+// while a cgroup made fresh at the same path is not. Generation kills
+// (KillGen, CloseRemove) remove their child and never meet it. The kills
+// that keep their cgroup are a worker's host cgroup (KillHost) and the
+// legacy sidecar's role child (KillSidecar); a strict group's own kill
+// keeps its directory too.
+//
+// Every kill marks the cgroup it is about to kill, by identity (device and
+// inode), before it writes cgroup.kill. The next start into that path
+// (Start, StartChild, PrepareStrict) removes the killed cgroup, and any
+// empty cgroups left below it, and then creates it as a first start does,
+// so it gets the same limits and controller settings from the same code,
+// under the same parent. The recreation happens at the start, not right
+// after the kill, for three reasons. The start is the one place that knows
+// what the cgroup must carry (its tier, or the parent's), so a recreation
+// cannot drift from a creation. One check there covers every kill, a strict
+// one included, without each kill site having to remember it. And a strict
+// handle may still pin the killed directory when the kill returns (it is the
+// handle's identity), so removing it then would strand the handle; at the
+// start a pinned cgroup is refused instead, and removed once the handle lets
+// go.
+//
+// A removal that fails (EBUSY: something is in it again) refuses the start
+// with ErrKilledCgroupKept. The mark stays, so every later start refuses too
+// until the cgroup can be removed. Marks are by identity, so a cgroup made
+// fresh at the same path is never mistaken for the killed one, and a
+// cgroup someone else removed leaves only a harmless stale mark (cgroupfs
+// inode numbers are not reused).
+//
+// The marks live in this process only. An agent that restarts with Setup
+// sweeps the empty carlos-exec@ directories a previous run left, killed
+// ones included, so it starts with fresh cgroups.
+
+// killedCgroups is every cgroup this process wrote cgroup.kill to that may
+// still exist, by identity. Package-wide, not per Manager: a strict handle's
+// kill has no Manager, and an identity names one cgroup on the box.
+var killedCgroups = struct {
+ sync.Mutex
+ ids map[strictIdentity]bool
+}{ids: make(map[strictIdentity]bool)}
+
+// markKilled records that id is about to take cgroup.kill. It is called
+// before the write, so a kill that fails half way still counts.
+func markKilled(id strictIdentity) {
+ killedCgroups.Lock()
+ killedCgroups.ids[id] = true
+ killedCgroups.Unlock()
+}
+
+func forgetKilled(id strictIdentity) {
+ killedCgroups.Lock()
+ delete(killedCgroups.ids, id)
+ killedCgroups.Unlock()
+}
+
+func wasKilled(id strictIdentity) bool {
+ killedCgroups.Lock()
+ defer killedCgroups.Unlock()
+ return killedCgroups.ids[id]
+}
+
+// anyKilled lets a start skip every stat while nothing was ever killed: on
+// a box that never kills a kept cgroup (the flag off, before Task 15's F6
+// path) the start path is the one it always was.
+func anyKilled() bool {
+ killedCgroups.Lock()
+ defer killedCgroups.Unlock()
+ return len(killedCgroups.ids) > 0
+}
+
+func reservedStrict(id strictIdentity) bool {
+ strictReservations.Lock()
+ defer strictReservations.Unlock()
+ return strictReservations.held[id]
+}
+
+// rmdirCgroup removes one cgroup directory. A seam so the temp-dir fixture,
+// whose interface files are ordinary files, can stand in for cgroupfs.
+var rmdirCgroup = func(path string) error { return unix.Rmdir(path) }
+
+// pathIdentity is the identity of the directory at path, not following a
+// symlink. ok is false when nothing is there.
+func pathIdentity(path string) (id strictIdentity, ok bool, err error) {
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ if errors.Is(err, unix.ENOENT) {
+ return strictIdentity{}, false, nil
+ }
+ return strictIdentity{}, false, err
+ }
+ return strictIdentity{uint64(st.Dev), st.Ino}, true, nil
+}
+
+// freshen makes sure no start into dir (the host's cgroup, or one of its
+// children) lands in a cgroup that took a kill: the host's cgroup first,
+// since a kill there killed everything below it, then dir. It runs under
+// the host's start lock (hostLock), so two starts of one host never both
+// remove: the first removes the killed cgroup, and the second finds the
+// fresh one, whose identity took no kill, and keeps it. The lock is held
+// only for the check and the removal, not across the start's clone3: a
+// start's own directory is never removed by another start, because only a
+// killed identity is removed and every start checks under this lock first.
+// What it does not order is a kill landing while a start is between its
+// check and its clone3; the activator never does that (a kept cgroup is
+// killed only once its leader has exited, and nothing of that host or role
+// is started until the proof returns).
+func (m *Manager) freshen(host, dir string) error {
+ if !anyKilled() {
+ return nil
+ }
+ mu := m.hostLock(host)
+ mu.Lock()
+ defer mu.Unlock()
+ hostDir := m.hostDir(host)
+ if err := m.freshenOne(host, hostDir); err != nil {
+ return err
+ }
+ if dir == hostDir {
+ return nil
+ }
+ return m.freshenOne(host, dir)
+}
+
+func (m *Manager) freshenOne(host, dir string) error {
+ id, ok, err := pathIdentity(dir)
+ if err != nil {
+ // Unknown is not fresh.
+ return fmt.Errorf("%w: %s: %w", ErrKilledCgroupKept, dir, err)
+ }
+ if !ok || !wasKilled(id) {
+ return nil
+ }
+ groups := 0
+ if err := removeKilledTree(dir, 0, &groups); err != nil {
+ m.logf("cgroup: %s: %s took cgroup.kill and could not be removed, so nothing is started in it: %v", host, filepath.Base(dir), err)
+ return fmt.Errorf("%w: %s: %w", ErrKilledCgroupKept, dir, err)
+ }
+ m.logf("cgroup: %s: %s took cgroup.kill; removed so the next spawn gets a fresh cgroup", host, filepath.Base(dir))
+ return nil
+}
+
+// removeKilledTree removes dir and every cgroup below it, deepest first:
+// rmdir refuses a cgroup with child cgroups, empty or not, and a tenant
+// running as the agent's uid may have made some. The kernel refuses to
+// remove a cgroup with a process in it, so a removal that succeeds also
+// proves it empty. A cgroup a strict handle still pins is refused rather
+// than removed under it. The walk is bounded as the cleanup walk is.
+func removeKilledTree(dir string, depth int, groups *int) error {
+ entries, err := os.ReadDir(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if depth+1 > maxCleanupDepth {
+ return fmt.Errorf("%w: cgroup tree deeper than %d", ErrStrictRefused, maxCleanupDepth)
+ }
+ if *groups++; *groups > maxCleanupGroups {
+ return fmt.Errorf("%w: more than %d cgroups below one host", ErrStrictRefused, maxCleanupGroups)
+ }
+ if err := removeKilledTree(filepath.Join(dir, e.Name()), depth+1, groups); err != nil {
+ return err
+ }
+ }
+ id, ok, err := pathIdentity(dir)
+ if err != nil || !ok {
+ return err
+ }
+ if reservedStrict(id) {
+ return fmt.Errorf("%w: a strict handle still holds %s", ErrStrictState, filepath.Base(dir))
+ }
+ if err := rmdirCgroup(dir); err != nil && !errors.Is(err, unix.ENOENT) {
+ return err
+ }
+ forgetKilled(id)
+ return nil
+}
+
+// removeEmptyDir is CleanupHost's and RemoveChild's os.Remove, which also
+// drops a kill mark the removed cgroup carried, so the set does not grow.
+func removeEmptyDir(path string) error {
+ id, ok, _ := pathIdentity(path)
+ err := os.Remove(path)
+ if err == nil && ok {
+ forgetKilled(id)
+ }
+ return err
+}
+
+// refuseKilledChild is pinChild's check on a child that already exists: a
+// strict child that took a kill is never pinned again for a spawn. Generation
+// numbers are never reused, and no production path re-pins a role child, so
+// a refusal costs nothing there; it keeps the rule whole.
+func refuseKilledChild(parent *Prepared, name string) error {
+ var st unix.Stat_t
+ if err := unix.Fstatat(int(parent.dir.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
+ return ErrStrictRefused
+ }
+ if wasKilled(strictIdentity{uint64(st.Dev), st.Ino}) {
+ return ErrStrictRefused
+ }
+ return nil
+}
diff --git a/internal/cgroup/recreate_linux_test.go b/internal/cgroup/recreate_linux_test.go
new file mode 100644
index 000000000..326de0449
--- /dev/null
+++ b/internal/cgroup/recreate_linux_test.go
@@ -0,0 +1,374 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// amadan #272: once cgroup.kill has been written to a cgroup, every later
+// clone3(CLONE_INTO_CGROUP) into that same cgroup is SIGKILLed at once. The
+// fixture has no kernel behind it, so these tests pin the bookkeeping: a
+// cgroup that took a kill is removed and made fresh (a new inode, its limit
+// files written again) before the next spawn into its path, and a removal
+// that fails refuses the spawn.
+
+// fakeCgroupRmdir stands in for rmdir on cgroupfs, where the interface files
+// do not count against it: the fixture's are ordinary files, so they go
+// first. fail, while non-nil, is returned instead. It records each removal.
+func fakeCgroupRmdir(t *testing.T, fail *error) *[]string {
+ t.Helper()
+ var removed []string
+ orig := rmdirCgroup
+ rmdirCgroup = func(path string) error {
+ if fail != nil && *fail != nil {
+ return *fail
+ }
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ if err := os.Remove(filepath.Join(path, e.Name())); err != nil {
+ return err
+ }
+ }
+ }
+ if err := unix.Rmdir(path); err != nil {
+ return err
+ }
+ removed = append(removed, path)
+ return nil
+ }
+ t.Cleanup(func() { rmdirCgroup = orig })
+ return &removed
+}
+
+// forgetKillsAfter clears the kill marks a test leaves, so a later test's
+// temp dir can never meet one.
+func forgetKillsAfter(t *testing.T) {
+ t.Helper()
+ t.Cleanup(func() {
+ killedCgroups.Lock()
+ clear(killedCgroups.ids)
+ killedCgroups.Unlock()
+ })
+}
+
+func inodeOf(t *testing.T, path string) uint64 {
+ t.Helper()
+ var st unix.Stat_t
+ if err := unix.Lstat(path, &st); err != nil {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+ return st.Ino
+}
+
+// killFixture gives dir the files a cleanup handle reads, empty, and writes
+// cgroup.kill through a cleanup handle on host (child "" is the host's own
+// cgroup), as KillHost and KillSidecar do.
+func killFixture(t *testing.T, m *Manager, host, child, dir string) {
+ t.Helper()
+ for name, body := range map[string]string{"cgroup.events": "populated 0\n", "cgroup.procs": "", "cgroup.kill": ""} {
+ writeFixture(t, filepath.Join(dir, name), body)
+ }
+ c, err := m.openCleanupAt(host, child, func(*os.File) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ if err := c.KillAndEmpty(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if got := readFixture(t, filepath.Join(dir, "cgroup.kill")); got != "1" {
+ t.Fatalf("cgroup.kill = %q, want 1", got)
+ }
+}
+
+// A worker's host cgroup (KillHost's) is killed and kept. The next start
+// into it removes it and creates it again, with its limit files written as
+// a first creation writes them, and a start that follows no kill removes
+// nothing.
+func TestAKilledHostCgroupIsRecreatedBeforeTheNextStart(t *testing.T) {
+ forgetKillsAfter(t)
+ quietOOM(t)
+ m, err := SetupAt(newDelegatedRoot(t), t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ removed := fakeCgroupRmdir(t, nil)
+ calls := fakeStart(t, func(*exec.Cmd) error { return nil })
+ const host = "worker.example.com"
+ dir := m.hostDir(host)
+ for range 2 {
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(*removed) != 0 {
+ t.Fatalf("starts that followed no kill removed %v", *removed)
+ }
+ before := inodeOf(t, dir)
+ killFixture(t, m, host, "", dir)
+ // A limit file left behind would say nothing about the recreation;
+ // scribble on it so only a fresh write can restore it.
+ writeFixture(t, filepath.Join(dir, "memory.max"), "max")
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if len(*calls) != 3 {
+ t.Fatalf("started %d times, want 3", len(*calls))
+ }
+ if len(*removed) != 1 || (*removed)[0] != dir {
+ t.Fatalf("removed %v, want only %s", *removed, dir)
+ }
+ if after := inodeOf(t, dir); after == before {
+ t.Fatalf("the respawn reused the killed cgroup (inode %d)", before)
+ }
+ for file, want := range map[string]string{
+ "memory.max": "536870912",
+ "memory.high": "483183819", // HighOf: max/10*9
+ "memory.swap.max": "536870912",
+ "memory.oom.group": "1",
+ } {
+ if got := readFixture(t, filepath.Join(dir, file)); got != want {
+ t.Errorf("%s = %q, want %q", file, got, want)
+ }
+ }
+ // The fresh cgroup took no kill: the next start keeps it.
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if len(*removed) != 1 {
+ t.Fatalf("a start after the recreation removed again: %v", *removed)
+ }
+}
+
+// A killed cgroup that will not go (EBUSY: something is in it again) is not
+// proven fresh, so the start refuses and execs nothing. Once it can be
+// removed, the next start proceeds into a fresh one.
+func TestAKilledCgroupThatCannotBeRemovedRefusesTheNextStart(t *testing.T) {
+ forgetKillsAfter(t)
+ quietOOM(t)
+ m, err := SetupAt(newDelegatedRoot(t), t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fail := error(unix.EBUSY)
+ removed := fakeCgroupRmdir(t, &fail)
+ calls := fakeStart(t, func(*exec.Cmd) error { return nil })
+ const host = "worker.example.com"
+ dir := m.hostDir(host)
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ before := inodeOf(t, dir)
+ killFixture(t, m, host, "", dir)
+ built := 0
+ cmd, err := m.Start(host, TierDefault, func() *exec.Cmd { built++; return buildTrue() })
+ if !errors.Is(err, ErrKilledCgroupKept) || !errors.Is(err, unix.EBUSY) || cmd != nil {
+ t.Fatalf("start into a killed cgroup that would not go = %v, %v; want ErrKilledCgroupKept (EBUSY)", cmd, err)
+ }
+ if built != 0 || len(*calls) != 1 {
+ t.Fatalf("the refused start built %d command(s) and started %d time(s) in all, want 0 and 1", built, len(*calls))
+ }
+ if inodeOf(t, dir) != before {
+ t.Fatal("the refused start replaced the cgroup")
+ }
+ // Still refused while it will not go: the mark outlives the failure.
+ if _, err := m.Start(host, TierDefault, buildTrue); !errors.Is(err, ErrKilledCgroupKept) {
+ t.Fatalf("second start = %v, want ErrKilledCgroupKept", err)
+ }
+ fail = nil
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if len(*removed) != 1 || inodeOf(t, dir) == before {
+ t.Fatalf("removed %v; the start after the removal succeeded must be in a fresh cgroup", *removed)
+ }
+}
+
+// The legacy sidecar's role child (KillSidecar's) is killed and kept. The
+// respawn gets a fresh role-sidecar under the very same parent, whose limit
+// and accounting are left as they were.
+func TestAKilledRoleSidecarIsRecreatedUnderTheSameParent(t *testing.T) {
+ forgetKillsAfter(t)
+ const host = "sc.example.com"
+ m, parent := genManager(t, host)
+ removed := fakeCgroupRmdir(t, nil)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ child := RoleChild("sidecar")
+ dir := filepath.Join(parent, child)
+ if _, err := m.StartChild(host, child, TierDefault, Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ parentInode, childInode := inodeOf(t, parent), inodeOf(t, dir)
+ killFixture(t, m, host, child, dir)
+ writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824") // a raise the respawn must not undo
+ writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 1\n")
+ if _, err := m.StartChild(host, child, TierDefault, Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if len(*removed) != 1 || (*removed)[0] != dir {
+ t.Fatalf("removed %v, want only %s", *removed, dir)
+ }
+ if inodeOf(t, dir) == childInode {
+ t.Fatal("the sidecar respawn reused the killed role child")
+ }
+ if inodeOf(t, parent) != parentInode {
+ t.Fatal("the sidecar respawn replaced the parent")
+ }
+ if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "1073741824" {
+ t.Fatalf("parent memory.max = %q, the respawn touched it", got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "memory.max")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a role child carries no limit of its own: %v", err)
+ }
+}
+
+// KillHost on a generation parent kills everything under it. The next start
+// of any child removes the whole killed tree, children first, and builds
+// the parent again as a cold start does: controllers delegated, the tier
+// written with memory.oom.group=0.
+func TestAKilledParentIsRecreatedWithItsChildren(t *testing.T) {
+ forgetKillsAfter(t)
+ const host = "gp.example.com"
+ m, parent := genManager(t, host)
+ removed := fakeCgroupRmdir(t, nil)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ sidecar := RoleChild("sidecar")
+ if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := m.StartChild(host, "g4", TierDefault, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ parentInode := inodeOf(t, parent)
+ killFixture(t, m, host, "", parent)
+ // The fixture's parent would lose its controller list with it; the
+ // kernel lists them on every new cgroup, so the fake rmdir's recreation
+ // needs it back.
+ orig := rmdirCgroup
+ rmdirCgroup = func(path string) error {
+ if err := orig(path); err != nil {
+ return err
+ }
+ if path == parent {
+ if err := os.Mkdir(parent, 0o755); err != nil {
+ return err
+ }
+ writeFixture(t, filepath.Join(parent, "cgroup.controllers"), "cpu io memory pids\n")
+ }
+ return nil
+ }
+ t.Cleanup(func() { rmdirCgroup = orig })
+ if _, err := m.StartChild(host, sidecar, TierDefault, Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]bool{filepath.Join(parent, sidecar): true, filepath.Join(parent, "g4"): true}
+ if len(*removed) != 3 || !want[(*removed)[0]] || !want[(*removed)[1]] || (*removed)[2] != parent {
+ t.Fatalf("removed %v, want both children then %s", *removed, parent)
+ }
+ if inodeOf(t, parent) == parentInode {
+ t.Fatal("the respawn reused the killed parent")
+ }
+ if _, err := os.Stat(filepath.Join(parent, "g4")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the killed generation child survived: %v", err)
+ }
+ for file, want := range map[string]string{
+ "cgroup.subtree_control": "+cpu +memory +io +pids",
+ "memory.max": "536870912",
+ "memory.oom.group": "0",
+ } {
+ if got := readFixture(t, filepath.Join(parent, file)); got != want {
+ t.Errorf("parent %s = %q, want %q", file, got, want)
+ }
+ }
+}
+
+// A killed cgroup that a strict handle still pins (its reservation is
+// held) is not removed under it: removing it would strand the handle, whose
+// identity is that directory. The start refuses until the handle lets go.
+func TestAKilledCgroupAStrictHandleHoldsIsNotRemoved(t *testing.T) {
+ forgetKillsAfter(t)
+ quietOOM(t)
+ m, err := SetupAt(newDelegatedRoot(t), t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ removed := fakeCgroupRmdir(t, nil)
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ const host = "pinned.example.com"
+ dir := m.hostDir(host)
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ killFixture(t, m, host, "", dir)
+ var st unix.Stat_t
+ if err := unix.Lstat(dir, &st); err != nil {
+ t.Fatal(err)
+ }
+ id := strictIdentity{uint64(st.Dev), st.Ino}
+ strictReservations.Lock()
+ strictReservations.held[id] = true
+ strictReservations.Unlock()
+ release := func() {
+ strictReservations.Lock()
+ delete(strictReservations.held, id)
+ strictReservations.Unlock()
+ }
+ t.Cleanup(release)
+ if _, err := m.Start(host, TierDefault, buildTrue); !errors.Is(err, ErrStrictState) || !errors.Is(err, ErrKilledCgroupKept) {
+ t.Fatalf("start into a pinned killed cgroup = %v, want ErrKilledCgroupKept and ErrStrictState", err)
+ }
+ if len(*removed) != 0 {
+ t.Fatalf("removed %v under a strict handle", *removed)
+ }
+ release()
+ if _, err := m.Start(host, TierDefault, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ if len(*removed) != 1 {
+ t.Fatalf("removed %v once the handle let go, want the killed cgroup", *removed)
+ }
+}
+
+// A strict role child that took a kill is never pinned again for a spawn.
+func TestAKilledStrictRoleChildIsNotPinnedAgain(t *testing.T) {
+ forgetKillsAfter(t)
+ p := strictFixture(t)
+ makeRoleFixture(t, p, "seed", "populated 0\n")
+ child, err := p.PrepareRoleChild("seed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ if err := child.KillAndEmpty(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if err := child.Close(); err != nil {
+ t.Fatal(err)
+ }
+ again, err := p.PrepareRoleChild("seed")
+ if err == nil {
+ again.Close()
+ t.Fatal("a killed role child was pinned again for a spawn")
+ }
+ if !errors.Is(err, ErrStrictRefused) {
+ t.Fatalf("err = %v, want ErrStrictRefused", err)
+ }
+}
diff --git a/internal/cgroup/recreate_other.go b/internal/cgroup/recreate_other.go
new file mode 100644
index 000000000..84e0b84c7
--- /dev/null
+++ b/internal/cgroup/recreate_other.go
@@ -0,0 +1,11 @@
+//go:build !linux
+
+package cgroup
+
+import "os"
+
+// No cgroup is ever killed off linux (Cleanup is unsupported there), so no
+// start has a killed cgroup to make fresh (recreate_linux.go).
+func (m *Manager) freshen(string, string) error { return nil }
+
+func removeEmptyDir(path string) error { return os.Remove(path) }
diff --git a/internal/cgroup/strict_gen_linux.go b/internal/cgroup/strict_gen_linux.go
index 923532316..ce71653f6 100644
--- a/internal/cgroup/strict_gen_linux.go
+++ b/internal/cgroup/strict_gen_linux.go
@@ -42,8 +42,15 @@ func pinChild(parent *Prepared, name string, beforeMkdir func() error, allowExis
return nil, err
}
}
- if err := unix.Mkdirat(int(parent.dir.Fd()), name, 0755); err != nil && (!allowExisting || !errors.Is(err, unix.EEXIST)) {
- return nil, ErrStrictRefused
+ if err := unix.Mkdirat(int(parent.dir.Fd()), name, 0755); err != nil {
+ if !allowExisting || !errors.Is(err, unix.EEXIST) {
+ return nil, ErrStrictRefused
+ }
+ // An existing child that took cgroup.kill is never reused (amadan
+ // #272). The caller holds the parent's mu.
+ if err := refuseKilledChild(parent, name); err != nil {
+ return nil, err
+ }
}
child := &Prepared{strictPreparedState: &strictPreparedState{parent: parent, host: parent.host, limits: map[string]*os.File{}, expected: map[string]string{}, entryName: name, start: func(cmd *exec.Cmd) error { return cmd.Start() }}}
diff --git a/internal/cgroup/strict_gen_linux_test.go b/internal/cgroup/strict_gen_linux_test.go
index dc6f873ae..556a94d1f 100644
--- a/internal/cgroup/strict_gen_linux_test.go
+++ b/internal/cgroup/strict_gen_linux_test.go
@@ -205,9 +205,18 @@ func TestGenerationKillAndEmptyTouchesOnlyItsChild(t *testing.T) {
if _, err := g2.Populated(); !errors.Is(err, ErrStrictState) {
t.Fatalf("handle still open after CloseRemove: %v", err)
}
- again, err := p.PrepareGen(2, TierDefault)
- if err != nil {
- t.Fatalf("reservation not released by CloseRemove: %v", err)
+ strictReservations.Lock()
+ held := strictReservations.held[g2.identity]
+ strictReservations.Unlock()
+ if held {
+ t.Fatal("reservation not released by CloseRemove")
+ }
+ // The directory survived its failed rmdir, but it took cgroup.kill, so
+ // it is never pinned for a spawn again (amadan #272).
+ if again, err := p.PrepareGen(2, TierDefault); !errors.Is(err, ErrStrictRefused) {
+ if again != nil {
+ again.Close()
+ }
+ t.Fatalf("re-pin of a killed generation child: %v, want ErrStrictRefused", err)
}
- again.Close()
}
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go
index 581e22466..245b8e12e 100644
--- a/internal/cgroup/strict_linux.go
+++ b/internal/cgroup/strict_linux.go
@@ -5,6 +5,7 @@ package cgroup
import (
"context"
"errors"
+ "fmt"
"io"
"os"
"os/exec"
@@ -231,6 +232,15 @@ func (m *Manager) prepareStrict(host string, limit Limit, genParent bool) (out *
}
}
}()
+ // A group that took cgroup.kill is removed and made fresh first (amadan
+ // #272). One a strict handle still holds stays, and the answer is the
+ // reservation's own ErrStrictState.
+ if e = m.freshen(host, m.hostDir(host)); e != nil {
+ if errors.Is(e, ErrStrictState) {
+ return nil, e
+ }
+ return nil, fmt.Errorf("%w: %w", ErrStrictRefused, e)
+ }
if e = unix.Mkdirat(int(root.Fd()), ExecName(host), 0755); e != nil && !errors.Is(e, unix.EEXIST) {
return nil, ErrStrictRefused
}
@@ -394,6 +404,9 @@ func (p *Prepared) KillAndEmpty(ctx context.Context) error {
return e
}
p.attempted = true
+ // Marked first, so no later start reuses this directory (amadan #272,
+ // recreate_linux.go).
+ markKilled(p.identity)
if e := strictWrite(p.kill, "1"); e != nil {
return ErrStrictRefused
}