carlos / platform Public

activator: a flag-off stop on a kernel with no cgroup.kill keeps the leader-only proof

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 097ca9300d5c09797cd96c621647653ad152dfcb parent ac9f3fe
6 files changed, +67 −4
  • internal/activator/activator_test.go +20 −0
  • internal/activator/backend_exec.go +4 −0
  • internal/activator/genbackend.go +15 −4
  • internal/cgroup/cleanup.go +3 −0
  • internal/cgroup/cleanup_linux.go +5 −0
  • internal/cgroup/cleanup_linux_test.go +20 −0
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 7a0add4d9..b5b96db95 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -6558,6 +6558,26 @@ func TestEmptyHostDispatch(t *testing.T) {
}
}
+// A kernel before 5.14 has no cgroup.kill, so the host cgroup cannot be
+// killed there at all. EmptyHost answers that as no delegation (the
+// leader-only proof, ledger 127), not as an unproven kill that would hold
+// every flag-off stop's lease on such a box.
+func TestEmptyHostWithNoCgroupKillIsTheLeaderOnlyProof(t *testing.T) {
+ eb := testExecBackend(t)
+ opened := 0
+ eb.openHostCleanup = func(string) (*cgroup.Cleanup, error) {
+ opened++
+ return nil, fmt.Errorf("%w: open cgroup.kill: no such file or directory", cgroup.ErrNoKill)
+ }
+ err := eb.EmptyHost(context.Background(), registry.Route{Host: "old-kernel.example.com"})
+ if opened != 1 {
+ t.Fatalf("host cgroup opened %d times, want 1", opened)
+ }
+ if !errors.Is(err, cgroup.ErrNoDelegation) || !errors.Is(err, cgroup.ErrNoKill) {
+ t.Fatalf("EmptyHost with no cgroup.kill = %v, want ErrNoDelegation naming ErrNoKill", err)
+ }
+}
+
// The pending-stop path (a leader that was still running when stop() asked)
// is held to the same proof once the leader exits.
func TestAPendingFlagOffStopProvesTheHostCgroupBeforeTheLeaseGoes(t *testing.T) {
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index bf5c3cb93..d4ea0c0a2 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -261,6 +261,10 @@ type ExecBackend struct {
// cgroup manager's own. Tests replace this seam, never the Cgroups
// field.
cgroupKill func(ctx context.Context, rt registry.Route, child string) error
+ // openHostCleanup is EmptyHost's open of the host cgroup. Nil:
+ // Cgroups.OpenCleanup. Tests replace this seam, never the Cgroups
+ // field.
+ openHostCleanup func(host string) (*cgroup.Cleanup, error)
// strictPreflight is NextSpawnStrict's seam for PrepareStrict's runtime
// preflight. Nil: Cgroups.StrictPreflight, the very function
// PrepareStrict runs, so S2 and the spawn cannot disagree.
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go
index 46f1f133c..a74d5ed3b 100644
--- a/internal/activator/genbackend.go
+++ b/internal/activator/genbackend.go
@@ -532,15 +532,26 @@ func (b *SplitBackend) KillHost(ctx context.Context, rt registry.Route) error {
// to recreate. A populated cgroup goes through KillAndEmpty, which the
// next start answers by making it fresh (Task 19). An absent cgroup is
// empty; no delegated cgroup at all is cgroup.ErrNoDelegation, which the
-// caller treats as the leader-only proof.
+// caller treats as the leader-only proof. So is a cgroup with no
+// cgroup.kill (cgroup.ErrNoKill, a kernel before 5.14): nothing could kill
+// a descendant there, and reading it as an unproven kill would hold every
+// flag-off stop's lease on such a box.
func (b *ExecBackend) EmptyHost(ctx context.Context, rt registry.Route) error {
- if b.Cgroups == nil {
- return cgroup.ErrNoDelegation
+ open := b.openHostCleanup
+ if open == nil {
+ if b.Cgroups == nil {
+ return cgroup.ErrNoDelegation
+ }
+ open = b.Cgroups.OpenCleanup
}
- c, err := b.Cgroups.OpenCleanup(rt.Host)
+ c, err := open(rt.Host)
if errors.Is(err, cgroup.ErrNoCgroup) {
return nil
}
+ if errors.Is(err, cgroup.ErrNoKill) {
+ log.Printf("activator: %s: this kernel has no cgroup.kill, so the host cgroup cannot be killed; its leader's exit is the only proof there is", rt.Host)
+ return fmt.Errorf("%w: %w", cgroup.ErrNoDelegation, err)
+ }
if err != nil {
return err
}
diff --git a/internal/cgroup/cleanup.go b/internal/cgroup/cleanup.go
index c48c9295e..8cd26b385 100644
--- a/internal/cgroup/cleanup.go
+++ b/internal/cgroup/cleanup.go
@@ -5,4 +5,7 @@ import "errors"
var (
ErrNoCgroup = errors.New("cgroup: existing host cgroup absent")
ErrNoDelegation = errors.New("cgroup: no delegated manager")
+ // ErrNoKill is a cleanup opened on a cgroup with no cgroup.kill, which
+ // a kernel before 5.14 does not have.
+ ErrNoKill = errors.New("cgroup: no cgroup.kill (a kernel before 5.14)")
)
diff --git a/internal/cgroup/cleanup_linux.go b/internal/cgroup/cleanup_linux.go
index 64bc29806..3d775e74d 100644
--- a/internal/cgroup/cleanup_linux.go
+++ b/internal/cgroup/cleanup_linux.go
@@ -103,6 +103,11 @@ func (m *Manager) openCleanupAt(host, child string, checkFS func(*os.File) error
return nil, err
}
p.kill, err = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY)
+ if errors.Is(err, unix.ENOENT) {
+ // The cgroup is there (its events were opened above), so a missing
+ // cgroup.kill is the kernel's, not a cgroup that went away.
+ return nil, fmt.Errorf("%w: %w", ErrNoKill, err)
+ }
if err != nil {
return nil, err
}
diff --git a/internal/cgroup/cleanup_linux_test.go b/internal/cgroup/cleanup_linux_test.go
index b3c00601d..d9dcdb4a9 100644
--- a/internal/cgroup/cleanup_linux_test.go
+++ b/internal/cgroup/cleanup_linux_test.go
@@ -188,6 +188,26 @@ func TestCleanupRejectsMissingControlsAndSymlinks(t *testing.T) {
}
}
+// A kernel before 5.14 has no cgroup.kill. A cleanup opened there names
+// that (ErrNoKill), so a caller can fall back to the leader-only proof
+// instead of reading it as an unproven kill on every stop. A cgroup.kill
+// that is there but a symlink is not that case.
+func TestCleanupNamesAMissingKillFile(t *testing.T) {
+ m, host, dir := cleanupFixture(t)
+ if err := os.Remove(filepath.Join(dir, "cgroup.kill")); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := m.openCleanup(host, func(*os.File) error { return nil }); c != nil || !errors.Is(err, ErrNoKill) {
+ t.Fatalf("missing cgroup.kill: %v %v, want ErrNoKill", c, err)
+ }
+ if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "cgroup.kill")); err != nil {
+ t.Fatal(err)
+ }
+ if c, err := m.openCleanup(host, func(*os.File) error { return nil }); c != nil || err == nil || errors.Is(err, ErrNoKill) {
+ t.Fatalf("symlinked cgroup.kill: %v %v, want a refusal that is not ErrNoKill", c, err)
+ }
+}
+
func mkCgroupFixture(t *testing.T, dir, procs, events string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {