carlos / platform Public

cgroup: reopen-only cleanup handle with no spawn authority (claim Plan 3 Task 4)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev b4ff0e8596aef43a29fac369e75c08e184d332de parent 4c12aec
5 files changed, +482 −0
  • internal/cgroup/cleanup.go +8 −0
  • internal/cgroup/cleanup_linux.go +236 −0
  • internal/cgroup/cleanup_linux_test.go +188 −0
  • internal/cgroup/cleanup_other.go +18 −0
  • internal/cgroup/cleanup_other_test.go +32 −0
diff --git a/internal/cgroup/cleanup.go b/internal/cgroup/cleanup.go
new file mode 100644
index 000000000..c48c9295e
--- /dev/null
+++ b/internal/cgroup/cleanup.go
@@ -0,0 +1,8 @@
+package cgroup
+
+import "errors"
+
+var (
+ ErrNoCgroup = errors.New("cgroup: existing host cgroup absent")
+ ErrNoDelegation = errors.New("cgroup: no delegated manager")
+)
diff --git a/internal/cgroup/cleanup_linux.go b/internal/cgroup/cleanup_linux.go
new file mode 100644
index 000000000..b5ceee2db
--- /dev/null
+++ b/internal/cgroup/cleanup_linux.go
@@ -0,0 +1,236 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "golang.org/x/sys/unix"
+)
+
+type Cleanup struct{ state *cleanupState }
+type cleanupState struct {
+ pin *Prepared
+ procs *os.File
+}
+
+func cleanupFilesystem(root *os.File) error {
+ var st unix.Statfs_t
+ if err := unix.Fstatfs(int(root.Fd()), &st); err != nil {
+ return err
+ }
+ if uint64(st.Type) != uint64(unix.CGROUP2_SUPER_MAGIC) {
+ return ErrStrictUnsupported
+ }
+ return nil
+}
+
+func (m *Manager) OpenCleanup(host string) (*Cleanup, error) {
+ return m.openCleanup(host, cleanupFilesystem)
+}
+
+func (m *Manager) openCleanup(host string, checkFS func(*os.File) error) (out *Cleanup, err error) {
+ if m == nil || m.root == "" {
+ return nil, ErrNoDelegation
+ }
+ if !controlname.ValidHostShape(host) {
+ return nil, ErrStrictRefused
+ }
+ root, err := os.OpenFile(m.root, os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
+ if err != nil {
+ return nil, err
+ }
+ p := &Prepared{strictPreparedState: &strictPreparedState{root: root, host: host}}
+ c := &Cleanup{state: &cleanupState{pin: p}}
+ defer func() {
+ if err != nil {
+ if c.state.procs != nil {
+ c.state.procs.Close()
+ }
+ p.closeFiles()
+ }
+ }()
+ if err = checkFS(root); err != nil {
+ return nil, err
+ }
+ p.dir, err = strictOpen(root, ExecName(host), unix.O_RDONLY|unix.O_DIRECTORY)
+ if errors.Is(err, unix.ENOENT) {
+ return nil, ErrNoCgroup
+ }
+ if err != nil {
+ return nil, err
+ }
+ p.identity, err = strictID(p.dir)
+ if err != nil {
+ return nil, err
+ }
+ p.events, err = strictOpen(p.dir, "cgroup.events", unix.O_RDONLY)
+ if err != nil {
+ return nil, err
+ }
+ p.kill, err = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY)
+ if err != nil {
+ return nil, err
+ }
+ c.state.procs, err = strictOpen(p.dir, "cgroup.procs", unix.O_RDONLY)
+ if err != nil {
+ return nil, err
+ }
+ if err = p.identityOK(); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+func (c *Cleanup) Populated() (bool, error) {
+ if c == nil || c.state == nil {
+ return false, ErrStrictState
+ }
+ p := c.state.pin
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return c.populationLocked()
+}
+
+func (c *Cleanup) populationLocked() (bool, error) {
+ p := c.state.pin
+ if err := p.identityOK(); err != nil {
+ return false, err
+ }
+ v, err := strictPopulation(p.events)
+ if err != nil {
+ return false, err
+ }
+ if err := p.identityOK(); err != nil {
+ return false, err
+ }
+ return v, nil
+}
+
+func (c *Cleanup) Members() ([]int, error) {
+ if c == nil || c.state == nil {
+ return nil, ErrStrictState
+ }
+ p := c.state.pin
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return c.membersLocked()
+}
+
+func (c *Cleanup) membersLocked() ([]int, error) {
+ p := c.state.pin
+ if err := p.identityOK(); err != nil {
+ return nil, err
+ }
+ if _, err := c.state.procs.Seek(0, 0); err != nil {
+ return nil, err
+ }
+ // cgroup.procs may exceed strictRead's 4096-byte bound. Scan every line.
+ scan := bufio.NewScanner(c.state.procs)
+ var members []int
+ for scan.Scan() {
+ raw := strings.TrimSpace(scan.Text())
+ pid, err := strconv.Atoi(raw)
+ if err != nil || pid <= 0 {
+ return nil, fmt.Errorf("%w: invalid cgroup member", ErrStrictRefused)
+ }
+ members = append(members, pid)
+ }
+ if err := scan.Err(); err != nil {
+ return nil, err
+ }
+ if err := p.identityOK(); err != nil {
+ return nil, err
+ }
+ return members, nil
+}
+
+func (c *Cleanup) KillAndEmpty(ctx context.Context) error {
+ if c == nil || c.state == nil {
+ return ErrStrictState
+ }
+ if ctx == nil {
+ return ErrStrictDeadline
+ }
+ deadline, ok := ctx.Deadline()
+ if !ok {
+ return ErrStrictDeadline
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if !time.Now().Before(deadline) {
+ return context.DeadlineExceeded
+ }
+ p := c.state.pin
+ lockTick := time.NewTicker(time.Millisecond)
+ defer lockTick.Stop()
+ for !p.mu.TryLock() {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-lockTick.C:
+ }
+ }
+ defer p.mu.Unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := p.identityOK(); err != nil {
+ return err
+ }
+ if err := strictWrite(p.kill, "1"); err != nil {
+ return err
+ }
+ tick := time.NewTicker(5 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ populated, err := c.populationLocked()
+ if err != nil {
+ return err
+ }
+ members, err := c.membersLocked()
+ if err != nil {
+ return err
+ }
+ if !populated && len(members) == 0 {
+ return ctx.Err()
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-tick.C:
+ }
+ }
+}
+
+func (c *Cleanup) Close() error {
+ if c == nil || c.state == nil {
+ return ErrStrictState
+ }
+ p := c.state.pin
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.closed {
+ return nil
+ }
+ p.closed = true
+ var errs []error
+ for _, f := range []*os.File{c.state.procs, p.events, p.kill, p.dir, p.root} {
+ if f != nil {
+ errs = append(errs, f.Close())
+ }
+ }
+ return errors.Join(errs...)
+}
diff --git a/internal/cgroup/cleanup_linux_test.go b/internal/cgroup/cleanup_linux_test.go
new file mode 100644
index 000000000..8e774dcc8
--- /dev/null
+++ b/internal/cgroup/cleanup_linux_test.go
@@ -0,0 +1,188 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func cleanupFixture(t *testing.T) (*Manager, string, string) {
+ t.Helper()
+ host, root := "cleanup.example", t.TempDir()
+ dir := filepath.Join(root, ExecName(host))
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{"cgroup.events": "populated 1\n", "cgroup.procs": "123\n456\n", "cgroup.kill": ""} {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return &Manager{root: root}, host, dir
+}
+
+func fixtureCleanup(t *testing.T, m *Manager, host string) *Cleanup {
+ t.Helper()
+ c, err := m.openCleanup(host, func(*os.File) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = c.Close() })
+ return c
+}
+
+func TestCleanupReopensAndDrains(t *testing.T) {
+ m, host, dir := cleanupFixture(t)
+ c := fixtureCleanup(t, m, host)
+ if _, exists := reflect.TypeOf(c).MethodByName("Start"); exists {
+ t.Fatal("cleanup grants spawn")
+ }
+ if populated, err := c.Populated(); err != nil || !populated {
+ t.Fatal(populated, err)
+ }
+ if members, err := c.Members(); err != nil || !reflect.DeepEqual(members, []int{123, 456}) {
+ t.Fatal(members, err)
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
+ defer cancel()
+ if err := c.KillAndEmpty(ctx); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal(err)
+ }
+ if b, err := os.ReadFile(filepath.Join(dir, "cgroup.kill")); err != nil || string(b) != "1" {
+ t.Fatalf("kill: %q %v", b, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte("populated 0\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ ctx2, cancel2 := context.WithTimeout(t.Context(), time.Second)
+ defer cancel2()
+ // Population alone is insufficient: the members still prevent success.
+ ctx3, cancel3 := context.WithTimeout(t.Context(), 30*time.Millisecond)
+ defer cancel3()
+ if err := c.KillAndEmpty(ctx3); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.KillAndEmpty(ctx2); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Populated(); !errors.Is(err, ErrStrictState) {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(dir); err != nil {
+ t.Fatal("close removed group", err)
+ }
+}
+
+func TestCleanupRefusals(t *testing.T) {
+ var absent *Manager
+ if _, err := absent.OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) {
+ t.Fatal(err)
+ }
+ if _, err := (&Manager{}).OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) {
+ t.Fatal(err)
+ }
+ m, host, dir := cleanupFixture(t)
+ if _, err := m.OpenCleanup(host); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal("ordinary filesystem admitted", err)
+ }
+ if _, err := m.openCleanup("../outside", func(*os.File) error { return nil }); !errors.Is(err, ErrStrictRefused) {
+ t.Fatal(err)
+ }
+ if _, err := m.openCleanup("missing.example", func(*os.File) error { return nil }); !errors.Is(err, ErrNoCgroup) {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(m.root, ExecName("missing.example"))); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("created missing group", err)
+ }
+ c := fixtureCleanup(t, m, host)
+ for _, body := range []string{"", "populated 2\n", "populated 0\npopulated 0\n", "broken\n"} {
+ if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Populated(); err == nil {
+ t.Fatal("malformed events accepted")
+ }
+ }
+ for _, body := range []string{"0\n", "-1\n", "oops\n", "9999999999999999999999999\n"} {
+ if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(body), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Members(); err == nil {
+ t.Fatal("malformed members accepted")
+ }
+ }
+ if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictDeadline) {
+ t.Fatal(err)
+ }
+ if err := c.KillAndEmpty(nil); !errors.Is(err, ErrStrictDeadline) {
+ t.Fatal(err)
+ }
+ if err := os.Rename(dir, dir+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Populated(); err == nil {
+ t.Fatal("replaced identity accepted")
+ }
+ if _, err := c.Members(); err == nil {
+ t.Fatal("replaced membership accepted")
+ }
+ if err := c.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCleanupDeadlineWhileLocked(t *testing.T) {
+ m, host, _ := cleanupFixture(t)
+ c := fixtureCleanup(t, m, host)
+ c.state.pin.mu.Lock()
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
+ defer cancel()
+ done := make(chan error, 1)
+ go func() { done <- c.KillAndEmpty(ctx) }()
+ select {
+ case err := <-done:
+ c.state.pin.mu.Unlock()
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatal(err)
+ }
+ case <-time.After(time.Second):
+ c.state.pin.mu.Unlock()
+ <-done
+ t.Fatal("cleanup exceeded its deadline waiting for a handle lock")
+ }
+}
+
+func TestCleanupRejectsMissingControlsAndSymlinks(t *testing.T) {
+ for _, name := range []string{"cgroup.events", "cgroup.procs", "cgroup.kill"} {
+ t.Run(name, func(t *testing.T) {
+ m, host, dir := cleanupFixture(t)
+ if err := os.Remove(filepath.Join(dir, name)); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil || errors.Is(err, ErrNoCgroup) {
+ t.Fatalf("missing control: %v %v", c, err)
+ }
+ if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, name)); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil {
+ t.Fatalf("symlink control: %v %v", c, err)
+ }
+ })
+ }
+}
diff --git a/internal/cgroup/cleanup_other.go b/internal/cgroup/cleanup_other.go
new file mode 100644
index 000000000..6b7c33abf
--- /dev/null
+++ b/internal/cgroup/cleanup_other.go
@@ -0,0 +1,18 @@
+//go:build !linux
+
+package cgroup
+
+import "context"
+
+type Cleanup struct{}
+
+func (m *Manager) OpenCleanup(string) (*Cleanup, error) {
+ if m == nil || m.root == "" {
+ return nil, ErrNoDelegation
+ }
+ return nil, ErrStrictUnsupported
+}
+func (*Cleanup) Populated() (bool, error) { return false, ErrStrictUnsupported }
+func (*Cleanup) Members() ([]int, error) { return nil, ErrStrictUnsupported }
+func (*Cleanup) KillAndEmpty(context.Context) error { return ErrStrictUnsupported }
+func (*Cleanup) Close() error { return ErrStrictUnsupported }
diff --git a/internal/cgroup/cleanup_other_test.go b/internal/cgroup/cleanup_other_test.go
new file mode 100644
index 000000000..6c6ec9c85
--- /dev/null
+++ b/internal/cgroup/cleanup_other_test.go
@@ -0,0 +1,32 @@
+//go:build !linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestCleanupUnsupported(t *testing.T) {
+ if _, err := (&Manager{root: t.TempDir()}).OpenCleanup("shop.example"); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal(err)
+ }
+ var m *Manager
+ if _, err := m.OpenCleanup("shop.example"); !errors.Is(err, ErrNoDelegation) {
+ t.Fatal(err)
+ }
+ c := &Cleanup{}
+ if _, err := c.Populated(); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal(err)
+ }
+ if _, err := c.Members(); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal(err)
+ }
+ if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal(err)
+ }
+ if err := c.Close(); !errors.Is(err, ErrStrictUnsupported) {
+ t.Fatal(err)
+ }
+}