From 00488e0a6eea2002cb5130e1bdd44a48abe3bcbd Mon Sep 17 00:00:00 2001 From: CMGS Date: Sun, 9 Aug 2026 20:47:21 +0700 Subject: [PATCH] cgroup: explain a rejected cpuset fence write instead of surfacing bare ENOSPC Changing cgroup_cpus while VM scopes are live makes the kernel reject the cpuset write with ENOSPC. That reaches the operator as "vm clone: prepare cgroup scope: no space left on device", which points at disk rather than at the fence, and checkScopePlacements does not catch it: it only inspects scopes carrying an explicit placement, while inheriting scopes are the common case. The clear-fence path had no check at all. Wrap the write error with the current fence, the requested one and the number of running VMs so the message says what to do. --- cgroup/cgroup.go | 16 ++++++++++++++-- cgroup/cgroup_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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")