stopevidence: pid 0 in cgroup.procs is an unidentifiable member, a stray (CI fix)
The kernel lists pid 0 in cgroup.procs for a member outside the reader's pid namespace. readProcs now accepts it (still refusing non-numeric, signed or negative entries); StepStray fails on it with a count of unidentifiable members, Stray carries a 0 marker, and it is never taken for the agent. TestReconstructReadsTheRealOwnLeaf now asserts the read succeeds and the verdict agrees with Stray, so it holds on a shared runner cgroup. New copy row MV-N181 (pending).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5 files changed,
+82
−7
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −0internal/moves/evidence.go+3 −1internal/stopevidence/reconstruct_linux.go+22 −4internal/stopevidence/reconstruct_linux_test.go+55 −1
CI passed — run details
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index aa34c8d7b..04db0fd77 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -69,7 +69,7 @@ var movesCopyIDs = []string{ |
| "MV-N157", "MV-N158", "MV-N159", "MV-N160", "MV-N161", "MV-N162", |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| - "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", |
| + "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 7292d1f8b..57c2347bf 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -227,6 +227,7 @@ Columns: id, file, text, status. |
| | MV-N178 | internal/console/apiinstancemove.go | the instance or its boxes changed under this request. read its state and try again | pending | |
| | MV-N179 | internal/console/apiinstancemove.go | the move could not be recorded and is held until an operator checks the record | pending | |
| | MV-N180 | internal/provision/provision.go | the files directory already holds files this move did not write | pending | |
| +| MV-N181 | internal/stopevidence/reconstruct_linux.go | <count> process(es) in the agent's cgroup cannot be identified | pending | |
| ## Plan 3 |
diff --git a/internal/moves/evidence.go b/internal/moves/evidence.go| index 95e39bab8..c98ca7900 100644 |
| --- a/internal/moves/evidence.go |
| +++ b/internal/moves/evidence.go |
| @@ -32,7 +32,9 @@ var ( |
| // observation is from durable, observable state, so a restarted agent |
| // writes the same report. Steps holds one Check per step id; a step that |
| // never ran is absent, and an absent step is not OK. Stray lists the pids |
| -// StepStray found in the agent's leaf besides the agent, for the alert. |
| +// StepStray found in the agent's leaf besides the agent, for the alert. A 0 |
| +// in it marks members the box could not identify (not visible in its pid |
| +// namespace). |
| type StoppedReport struct { |
| V int `json:"v"` |
| Role string `json:"role"` |
diff --git a/internal/stopevidence/reconstruct_linux.go b/internal/stopevidence/reconstruct_linux.go| index fd6e3db42..be40b6751 100644 |
| --- a/internal/stopevidence/reconstruct_linux.go |
| +++ b/internal/stopevidence/reconstruct_linux.go |
| @@ -187,12 +187,24 @@ func stopTree(ctx context.Context, host string, open func(string) (treeGroup, er |
| return moves.Check{}, err |
| } |
| +// strayCheck is StepStray's verdict on one read of the agent's leaf. A pid |
| +// other than the agent's fails it with that pid. A 0 entry (the kernel's |
| +// name for a member outside this process's pid namespace) fails it too: it |
| +// is a process the box cannot identify, so it is never taken for the agent |
| +// and never for nothing. |
| func strayCheck(pids []int, self int) moves.Check { |
| + unknown := 0 |
| for _, p := range pids { |
| - if p != self { |
| + switch { |
| + case p == 0: |
| + unknown++ |
| + case p != self: |
| return moves.Check{Reason: fmt.Sprintf("pid %d in the agent's cgroup is not the agent", p)} |
| } |
| } |
| + if unknown > 0 { |
| + return moves.Check{Reason: fmt.Sprintf("%d process(es) in the agent's cgroup cannot be identified", unknown)} |
| + } |
| return moves.Check{OK: true} |
| } |
| @@ -209,7 +221,9 @@ func agentLeaf(procRoot string, self int) ([]int, error) { |
| return pids, nil |
| } |
| -// strays is every pid but self across the reads, sorted, each once. |
| +// strays is every pid but self across the reads, sorted, each once. A 0 |
| +// is the marker for members the box could not identify (once, however many |
| +// there were; the step's reason carries the count). |
| func strays(self int, reads ...[]int) []int { |
| var out []int |
| for _, pids := range reads { |
| @@ -338,7 +352,11 @@ func unescapeMount(s string) string { |
| return b.String() |
| } |
| -// readProcs parses cgroup.procs: one pid per line. |
| +// readProcs parses cgroup.procs: one pid per line. The kernel lists pid 0 |
| +// for a member that is not visible in the reader's pid namespace; it is |
| +// returned as 0, for the caller to treat as an unidentifiable member. Any |
| +// other entry that is not a plain non-negative number is an error. |
| + |
| func readProcs(file string) ([]int, error) { |
| raw, err := os.ReadFile(file) |
| if err != nil { |
| @@ -351,7 +369,7 @@ func readProcs(file string) ([]int, error) { |
| continue |
| } |
| pid, err := strconv.Atoi(line) |
| - if err != nil || pid <= 0 { |
| + if err != nil || pid < 0 || line[0] == '+' || line[0] == '-' { |
| return nil, fmt.Errorf("stopevidence: %s: invalid pid %q", file, line) |
| } |
| pids = append(pids, pid) |
diff --git a/internal/stopevidence/reconstruct_linux_test.go b/internal/stopevidence/reconstruct_linux_test.go| index f533a71af..c38ee88fc 100644 |
| --- a/internal/stopevidence/reconstruct_linux_test.go |
| +++ b/internal/stopevidence/reconstruct_linux_test.go |
| @@ -70,7 +70,10 @@ func TestReconstructStopEvidenceKillsTheWholeTree(t *testing.T) { |
| // Step 2 against the running kernel: the leaf /proc/self/cgroup names is |
| // found under the cgroup2 mount /proc/self/mountinfo names, and its |
| // cgroup.procs holds this process. Whether StepStray passes depends on |
| -// what else shares the test's cgroup (alone in a fresh scope, it passes). |
| +// what else shares the test's cgroup (alone in a fresh scope, it passes; |
| +// on a CI runner the leaf holds other processes, some listed as pid 0 |
| +// because they are outside this namespace, and it fails). The read itself |
| +// must succeed either way, and the step's verdict must agree with Stray. |
| func TestReconstructReadsTheRealOwnLeaf(t *testing.T) { |
| if _, ok := cgroup.ParseSelfCgroup(readOrSkip(t, "/proc/self/cgroup")); !ok { |
| t.Skip("no cgroup v2 leaf for this process") |
| @@ -89,6 +92,11 @@ func TestReconstructReadsTheRealOwnLeaf(t *testing.T) { |
| t.Fatal("the agent counted itself as a stray") |
| } |
| } |
| + // A pass means the agent is alone; a failure must be accounted for by |
| + // the other members the read found, 0 marking those it cannot name. |
| + if c := rep.Steps[moves.StepStray]; c.OK != (len(rep.Stray) == 0) { |
| + t.Fatalf("stray step %+v disagrees with the %d other members read: %v", c, len(rep.Stray), rep.Stray) |
| + } |
| t.Logf("stray step %+v, %d others in the leaf", rep.Steps[moves.StepStray], len(rep.Stray)) |
| } |
| @@ -318,6 +326,52 @@ func TestReconstructStrayInTheAgentsLeaf(t *testing.T) { |
| } |
| } |
| +// The kernel lists pid 0 in cgroup.procs for a member the reader's pid |
| +// namespace cannot name (a CI runner's cgroup holds such members). It is |
| +// a process this box cannot account for: it fails StepStray with no error, |
| +// is never taken for the agent, and is marked in Stray by the 0 entry. |
| +func TestReconstructUnidentifiableMemberIsAStray(t *testing.T) { |
| + self := os.Getpid() |
| + if c := strayCheck([]int{self, 0}, self); c.OK || c.Reason != "1 process(es) in the agent's cgroup cannot be identified" { |
| + t.Fatalf("strayCheck with the agent and a 0: %+v", c) |
| + } |
| + if c := strayCheck([]int{0, self, 0}, self); c.OK || c.Reason != "2 process(es) in the agent's cgroup cannot be identified" { |
| + t.Fatalf("strayCheck with two 0s: %+v", c) |
| + } |
| + f := newBoxFixture(t, self, 0) |
| + rep, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)) |
| + if err != nil { |
| + t.Fatalf("a pid 0 entry is not a parse error: %v", err) |
| + } |
| + if c := rep.Steps[moves.StepStray]; c.OK || c.Reason == "" || rep.TreeStopped() { |
| + t.Fatalf("stray: %+v", rep) |
| + } |
| + if !reflect.DeepEqual(rep.Stray, []int{0}) { |
| + t.Fatalf("Stray %v", rep.Stray) |
| + } |
| + // Never the agent itself: a leaf of only 0s does not name the agent. |
| + f.setLeaf(t, 0) |
| + if _, err := reconstruct(context.Background(), f.inputs(), opener(&fakeTree{}, nil)); err == nil { |
| + t.Fatal("a leaf of only pid 0 passed as the agent's") |
| + } |
| +} |
| + |
| +// Genuinely malformed entries are still refused. |
| +func TestReadProcsRefusesMalformedEntries(t *testing.T) { |
| + for _, body := range []string{"abc\n", "-1\n", "-0\n", "12x\n", "1 2\n"} { |
| + file := filepath.Join(t.TempDir(), "cgroup.procs") |
| + write(t, file, body) |
| + if _, err := readProcs(file); err == nil { |
| + t.Errorf("%q accepted", body) |
| + } |
| + } |
| + file := filepath.Join(t.TempDir(), "cgroup.procs") |
| + write(t, file, "7\n0\n\n9\n") |
| + if got, err := readProcs(file); err != nil || !reflect.DeepEqual(got, []int{7, 0, 9}) { |
| + t.Fatalf("got %v, %v", got, err) |
| + } |
| +} |
| + |
| // The leaf is read where /proc/self/mountinfo mounts the hierarchy, so a |
| // leaf that names the agent but is not the agent's is an error, not a pass. |
| func TestReconstructRefusesALeafWithoutTheAgent(t *testing.T) { |