diff --git a/cgroup/cgroup.go b/cgroup/cgroup.go index 0e324f73..ec0092c3 100644 --- a/cgroup/cgroup.go +++ b/cgroup/cgroup.go @@ -272,7 +272,7 @@ func reconcileFence(parentDir, fence string) error { current, err := readControl(parentDir, cpusetName) if fence == "" { if err == nil && current != "" { - return writeControl(parentDir, cpusetName, "\n") + return explainFenceWrite(parentDir, current, fence, writeControl(parentDir, cpusetName, "\n")) } return nil } @@ -288,7 +288,7 @@ func reconcileFence(parentDir, fence string) error { if err := checkScopePlacements(parentDir, fence); err != nil { return err } - return writeControl(parentDir, cpusetName, fence) + return explainFenceWrite(parentDir, current, fence, writeControl(parentDir, cpusetName, fence)) } // placeScope applies a per-VM placement; the kernel silently degrades ungrantable requests to the parent set, so the subset check is cocoon's. @@ -355,6 +355,18 @@ func checkScopePlacements(parentDir, fence string) error { return nil } +// explainFenceWrite names the live VMs behind a rejected fence write: the kernel refuses to re-home populated cgroups and reports only ENOSPC, which reads as an unrelated disk-full error. +func explainFenceWrite(parentDir, current, fence string, err error) error { + if err == nil { + return nil + } + ids, listErr := ListScopeVMIDs(parentDir) + if listErr != nil || len(ids) == 0 { + return err + } + return fmt.Errorf("cgroup_cpus %q -> %q rejected with %d VM(s) running; the fence is machine-wide, drain them first: %w", current, fence, len(ids), err) +} + func forEachLevel(rel string, fn func(dir string) error) error { if err := fn(Root); err != nil { return err diff --git a/cgroup/cgroup_test.go b/cgroup/cgroup_test.go index 1eaa597c..2d25cff0 100644 --- a/cgroup/cgroup_test.go +++ b/cgroup/cgroup_test.go @@ -1,6 +1,7 @@ package cgroup import ( + "errors" "os" "path/filepath" "slices" @@ -210,6 +211,29 @@ func TestReconcileFenceCanonicalEquality(t *testing.T) { } } +func TestExplainFenceWriteNamesLiveVMs(t *testing.T) { + parent := t.TempDir() + enospc := errors.New("no space left on device") + + if got := explainFenceWrite(parent, "0-14", "", enospc); got != enospc { + t.Errorf("no live scope: got %v, want the error unchanged", got) + } + if got := explainFenceWrite(parent, "0-14", "", nil); got != nil { + t.Errorf("successful write: got %v, want nil", got) + } + + if err := os.Mkdir(ScopeDir(parent, "X"), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + got := explainFenceWrite(parent, "0-14", "", enospc) + if !errors.Is(got, enospc) { + t.Fatalf("wrapped error dropped the cause: %v", got) + } + if !strings.Contains(got.Error(), "1 VM(s) running") { + t.Errorf("message does not name the live VMs: %v", got) + } +} + func TestPlaceScopeReadGate(t *testing.T) { parent := t.TempDir() dir := ScopeDir(parent, "X")