carlos / platform Public

offline-move: cgroup.HostProcs, the .offline-moves known name, layout.RawInstanceDir

cgroup.HostProcs counts the processes left for a host in its exec cgroup
and, when given, a unit or slice cgroup, recursively. An absent cgroup
is 0. With no delegated subtree, or off Linux, it answers ErrUnknown,
which the offline move's source report records as not stopped.

layout: .offline-moves (where the offline move parks local state by
rename) is a platform name, and RawInstanceDir exports the raw instance
directory for the move's park of a nested source (a rename through the
bind mount answers EXDEV).

Plan: docs/superpowers/plans/2026-10-20-offline-operator-move.md, Task 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1b561a9352126d7caa53be561f818c000cf46862 parent 924ecc9
6 files changed, +275 −1
  • internal/cgroup/hostprocs.go +28 −0
  • internal/cgroup/hostprocs_linux.go +109 −0
  • internal/cgroup/hostprocs_linux_test.go +115 −0
  • internal/cgroup/hostprocs_other.go +7 −0
  • internal/layout/extras.go +15 −0
  • internal/layout/extras_test.go +1 −1
diff --git a/internal/cgroup/hostprocs.go b/internal/cgroup/hostprocs.go
new file mode 100644
index 000000000..d2f950abc
--- /dev/null
+++ b/internal/cgroup/hostprocs.go
@@ -0,0 +1,28 @@
+package cgroup
+
+import "errors"
+
+// ErrUnknown is HostProcs saying it cannot tell how many processes a host
+// has left: the platform has no cgroup v2 to read (darwin, windows), or the
+// agent runs without a delegated subtree, so its exec children share the
+// agent's own cgroup and cannot be told apart. A caller records it as "not
+// stopped", never as zero (the offline move's source report, Task 3 of
+// docs/superpowers/plans/2026-10-20-offline-operator-move.md).
+var ErrUnknown = errors.New("cgroup: the host's processes cannot be counted on this box")
+
+// HostProcs counts the processes left for host on this box:
+//
+// - in the host's exec cgroup, root/carlos-exec@<host> (ExecName), and
+// every cgroup below it (an exec child's sidecar and worker groups);
+// - when unit is set, in the cgroup directory unit names and every cgroup
+// below it. unit is an absolute path in the cgroup filesystem: for a
+// nested row the host's slice, which holds its run, generation, seed,
+// sidecar and cmd units alike.
+//
+// root is the delegated subtree (Manager.Root). A cgroup that does not
+// exist holds no process and counts 0. An empty root is ErrUnknown, since
+// without delegation the exec children run in the agent's own cgroup.
+// Reading never writes, moves or signals anything.
+func HostProcs(root, host, unit string) (int, error) {
+ return hostProcs(root, host, unit)
+}
diff --git a/internal/cgroup/hostprocs_linux.go b/internal/cgroup/hostprocs_linux.go
new file mode 100644
index 000000000..c4204423c
--- /dev/null
+++ b/internal/cgroup/hostprocs_linux.go
@@ -0,0 +1,109 @@
+package cgroup
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// hostProcsMaxDepth bounds the walk below a host's cgroup. The platform
+// nests at most a few levels (exec group, role child, generation); a tree
+// deeper than this is not one it made, and is reported rather than walked.
+const hostProcsMaxDepth = 16
+
+func hostProcs(root, host, unit string) (int, error) {
+ if root == "" {
+ return 0, ErrUnknown
+ }
+ if host == "" || strings.ContainsAny(host, "/\x00") || host == "." || host == ".." {
+ return 0, fmt.Errorf("cgroup: %q is not a host name", host)
+ }
+ n, err := countProcs(filepath.Join(root, ExecName(host)), 0)
+ if err != nil {
+ return 0, err
+ }
+ if unit != "" {
+ if !filepath.IsAbs(unit) {
+ return 0, fmt.Errorf("cgroup: the unit cgroup %q is not an absolute path", unit)
+ }
+ m, err := countProcs(filepath.Clean(unit), 0)
+ if err != nil {
+ return 0, err
+ }
+ n += m
+ }
+ return n, nil
+}
+
+// countProcs is the number of pids listed in dir's cgroup.procs and in every
+// cgroup below it. An absent dir is 0. Symlinks are never followed: cgroupfs
+// has none, so a link is not a cgroup.
+func countProcs(dir string, depth int) (int, error) {
+ if depth > hostProcsMaxDepth {
+ return 0, fmt.Errorf("cgroup: %s is nested deeper than %d levels", dir, hostProcsMaxDepth)
+ }
+ st, err := os.Lstat(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ if !st.IsDir() {
+ return 0, fmt.Errorf("cgroup: %s is not a cgroup directory", dir)
+ }
+ n, err := readProcsFile(filepath.Join(dir, "cgroup.procs"))
+ if err != nil {
+ return 0, err
+ }
+ ents, err := os.ReadDir(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return n, nil // removed while read: its processes are gone with it
+ }
+ if err != nil {
+ return 0, err
+ }
+ for _, e := range ents {
+ if !e.IsDir() || e.Type()&os.ModeSymlink != 0 {
+ continue
+ }
+ m, err := countProcs(filepath.Join(dir, e.Name()), depth+1)
+ if err != nil {
+ return 0, err
+ }
+ n += m
+ }
+ return n, nil
+}
+
+// readProcsFile counts the non-empty lines of a cgroup.procs file. A file
+// that is gone (the cgroup was removed between the listing and the read)
+// lists nothing.
+func readProcsFile(path string) (int, error) {
+ f, err := os.Open(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ defer f.Close()
+ n := 0
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ if strings.TrimSpace(sc.Text()) != "" {
+ n++
+ }
+ }
+ if err := sc.Err(); err != nil {
+ // A cgroup removed while it was read answers ENODEV.
+ if _, statErr := os.Stat(filepath.Dir(path)); errors.Is(statErr, os.ErrNotExist) {
+ return 0, nil
+ }
+ return 0, err
+ }
+ return n, nil
+}
diff --git a/internal/cgroup/hostprocs_linux_test.go b/internal/cgroup/hostprocs_linux_test.go
new file mode 100644
index 000000000..e96d1528f
--- /dev/null
+++ b/internal/cgroup/hostprocs_linux_test.go
@@ -0,0 +1,115 @@
+package cgroup
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+)
+
+// writeProcs makes dir a fixture cgroup listing pids.
+func writeProcs(t *testing.T, dir string, pids ...int) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ body := ""
+ for _, p := range pids {
+ body += strconv.Itoa(p) + "\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// HostProcs over a fixture cgroupfs: the exec group and every group below
+// it count, and so does the unit's group; absent groups count 0; another
+// host's group never counts.
+func TestHostProcsCountsTheHostsGroupsOnly(t *testing.T) {
+ root := t.TempDir()
+ const host = "shop.example.com"
+ if n, err := HostProcs(root, host, ""); err != nil || n != 0 {
+ t.Fatalf("no group: (%d, %v), want (0, nil)", n, err)
+ }
+ writeProcs(t, filepath.Join(root, ExecName(host)))
+ if n, err := HostProcs(root, host, ""); err != nil || n != 0 {
+ t.Fatalf("an empty group: (%d, %v), want (0, nil)", n, err)
+ }
+ writeProcs(t, filepath.Join(root, ExecName(host)), 101)
+ writeProcs(t, filepath.Join(root, ExecName(host), "sidecar"), 102, 103)
+ writeProcs(t, filepath.Join(root, ExecName("other.example.com")), 201, 202, 203)
+ if n, err := HostProcs(root, host, ""); err != nil || n != 3 {
+ t.Fatalf("exec group and child: (%d, %v), want (3, nil)", n, err)
+ }
+ slice := filepath.Join(t.TempDir(), "carlos.slice", "carlos-instances.slice", "carlos-instances-shop.slice")
+ if n, err := HostProcs(root, host, slice); err != nil || n != 3 {
+ t.Fatalf("an absent unit group: (%d, %v), want (3, nil)", n, err)
+ }
+ writeProcs(t, filepath.Join(slice, "carlos-instance@shop.service"), 301)
+ if n, err := HostProcs(root, host, slice); err != nil || n != 4 {
+ t.Fatalf("with the unit's group: (%d, %v), want (4, nil)", n, err)
+ }
+ if _, err := HostProcs(root, host, "relative/slice"); err == nil {
+ t.Fatal("a relative unit path was read")
+ }
+ for _, bad := range []string{"", "..", "a/b"} {
+ if _, err := HostProcs(root, bad, ""); err == nil {
+ t.Fatalf("host %q was read", bad)
+ }
+ }
+}
+
+// Without a delegated subtree the exec children cannot be told apart from
+// the agent's own processes: unknown, never 0.
+func TestHostProcsWithoutDelegationIsUnknown(t *testing.T) {
+ if _, err := HostProcs("", "shop.example.com", ""); !errors.Is(err, ErrUnknown) {
+ t.Fatalf("no root: %v, want ErrUnknown", err)
+ }
+}
+
+// A real process in a real cgroup, where the kernel lets this test make one:
+// a child group of the test's own cgroup, with a sleep moved into it. Where
+// the cgroup is not writable (the usual case outside a delegated unit) the
+// fixture test above is the witness.
+func TestHostProcsSeesARealProcess(t *testing.T) {
+ b, err := os.ReadFile("/proc/self/cgroup")
+ if err != nil {
+ t.Skipf("no /proc/self/cgroup: %v", err)
+ }
+ rel, ok := ParseSelfCgroup(string(b))
+ if !ok {
+ t.Skip("no cgroup v2 path")
+ }
+ parent := filepath.Join("/sys/fs/cgroup", rel)
+ root := filepath.Join(parent, "hostprocs-test-"+strconv.Itoa(os.Getpid()))
+ const host = "shop.example.com"
+ group := filepath.Join(root, ExecName(host))
+ if err := os.MkdirAll(group, 0o755); err != nil {
+ t.Skipf("this cgroup is not writable: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = os.Remove(group)
+ _ = os.Remove(root)
+ })
+ cmd := exec.Command("sleep", "30")
+ if err := cmd.Start(); err != nil {
+ t.Skipf("no sleep: %v", err)
+ }
+ t.Cleanup(func() {
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ })
+ if err := os.WriteFile(filepath.Join(group, "cgroup.procs"), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
+ t.Skipf("the kernel refused the move: %v", err)
+ }
+ if n, err := HostProcs(root, host, ""); err != nil || n != 1 {
+ t.Fatalf("a sleep in the host's group: (%d, %v), want (1, nil)", n, err)
+ }
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ if n, err := HostProcs(root, host, ""); err != nil || n != 0 {
+ t.Fatalf("after the sleep exited: (%d, %v), want (0, nil)", n, err)
+ }
+}
diff --git a/internal/cgroup/hostprocs_other.go b/internal/cgroup/hostprocs_other.go
new file mode 100644
index 000000000..ede2ef0df
--- /dev/null
+++ b/internal/cgroup/hostprocs_other.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package cgroup
+
+// hostProcs has no cgroup v2 to read off Linux: every answer is unknown,
+// which a report records as not stopped.
+func hostProcs(string, string, string) (int, error) { return 0, ErrUnknown }
diff --git a/internal/layout/extras.go b/internal/layout/extras.go
index 5fef52ff6..329fdf930 100644
--- a/internal/layout/extras.go
+++ b/internal/layout/extras.go
@@ -321,6 +321,17 @@ func FlatLeftovers(dataDir, host string, hosts []string) []string {
return ours
}
+// RawInstanceDir is a nested host's instance directory through the raw
+// directory behind the /data/instances bind mount, for a caller outside this
+// package that renames entries out of it. The offline operator move parks a
+// nested source's contents this way (docs/superpowers/plans/2026-10-20-
+// offline-operator-move.md, Task 3): a rename through the mount answers
+// EXDEV, as the pass's own renames would (Artifact). rename(2) and lstat(2)
+// act on names, never on what a planted link points at; a caller uses only
+// those and a listing of the directory itself, and never follows a link
+// beneath it.
+func RawInstanceDir(dataDir, host string) string { return rawInstanceDir(dataDir, host) }
+
// platformNames are the entries the platform itself keeps directly under
// the data directory. UnattributedEntries leaves them out of its report.
var platformNames = map[string]bool{
@@ -328,6 +339,10 @@ var platformNames = map[string]bool{
"instances": true, ".instances": true, "layout": true, "system": true,
"moves": true, ".moves": true, ".hostexec": true, "litestream.env": true,
"lost+found": true,
+ // The offline operator move's park directory: local state it renames
+ // aside, never deletes (docs/superpowers/plans/2026-10-20-offline-
+ // operator-move.md).
+ ".offline-moves": true,
}
// UnattributedEntries lists the entries directly under dataDir that no
diff --git a/internal/layout/extras_test.go b/internal/layout/extras_test.go
index 42b105af4..4737ca688 100644
--- a/internal/layout/extras_test.go
+++ b/internal/layout/extras_test.go
@@ -88,7 +88,7 @@ func TestValidateJudgesTheJournalsExtraNames(t *testing.T) {
// wrote, which no move can take.
func TestUnattributedEntriesSkipsHostsAndPlatformNames(t *testing.T) {
dd := t.TempDir()
- for _, name := range []string{"blobs", "registry.db", "layout", ".instances", "instances", host, host + "-blobs", "cache.tmp"} {
+ for _, name := range []string{"blobs", "registry.db", "layout", ".instances", "instances", ".offline-moves", host, host + "-blobs", "cache.tmp"} {
if err := os.MkdirAll(filepath.Join(dd, name), 0o700); err != nil {
t.Fatal(err)
}