Skip to content

Commit 0cf7152

Browse files
AbirAbbasclaude
andcommitted
processgroup: a child counts only while its parent is still the same
Review fix: the macOS walk now reads a parent's listed children first and re-reads the parent after them. A pid is never handed on while its process lives, so a parent with the same start time after its children were read was their parent while they were read; a parent and child whose pids both changed hands just after the parent's own re-read can no longer bring a stranger's busy child into the job's reading. The partial-sum regression now starts from one complete, quiet reading, so it also fails if a partial reading ever keeps that earlier baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6ac0fb7 commit 0cf7152

3 files changed

Lines changed: 72 additions & 20 deletions

File tree

‎internal/processgroup/usage_darwin.go‎

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ func (g Group) Usage() (Usage, bool) {
4444
// subtreeUsage walks the tree below root through each process's children, so
4545
// a child that changed process groups is still the job's.
4646
//
47-
// A PARENT IS RE-READ AFTER ITS CHILDREN ARE LISTED. A queued process can exit
48-
// and its pid be handed to another process before its children are asked for;
49-
// the children of that stranger would pass the parent-pid check. Comparing the
50-
// parent's start time before and after the listing keeps them out.
47+
// A PARENT IS RE-READ AFTER ITS CHILDREN ARE READ. A queued process can exit
48+
// and its pid be handed to another process while its children are listed or
49+
// read; the children of that stranger would pass the parent-pid check. A pid is
50+
// never handed on while its process lives, so a parent that reads with the same
51+
// start time after its children were read was their parent while they were
52+
// read, and only then are they admitted.
5153
//
5254
// A listing that filled even the largest buffer, a walk that reached
5355
// [usageWalkLimit], and a process that could not be read for any reason other
@@ -82,19 +84,7 @@ func subtreeUsage(root int) (Usage, bool) {
8284
default:
8385
return Usage{}, false
8486
}
85-
again, err := usageReader.task(parent.PID)
86-
if err != nil || again.Start != parent.Start || again.PID != parent.PID {
87-
if next == 0 {
88-
return Usage{}, false
89-
}
90-
// A parent that exited or whose pid changed hands left the tree with
91-
// its children; one that merely could not be read leaves them out of
92-
// this sum only, so the sum is partial.
93-
if err != nil && !errors.Is(err, unix.ESRCH) {
94-
usage.CPUPartial = true
95-
}
96-
continue
97-
}
87+
var read []darwinproc.TaskInfo
9888
for _, pid := range children {
9989
if len(seen) >= usageWalkLimit {
10090
usage.CPUPartial = true
@@ -114,8 +104,22 @@ func subtreeUsage(root int) (Usage, bool) {
114104
if err != nil || child.PID != pid || child.PPID != parent.PID || child.Status == darwinproc.StatusZombie {
115105
continue
116106
}
117-
queue = append(queue, child)
107+
read = append(read, child)
108+
}
109+
again, err := usageReader.task(parent.PID)
110+
if err != nil || again.Start != parent.Start || again.PID != parent.PID {
111+
if next == 0 {
112+
return Usage{}, false
113+
}
114+
// A parent that exited or whose pid changed hands left the tree with
115+
// its children; one that merely could not be read leaves them out of
116+
// this sum only, so the sum is partial.
117+
if err != nil && !errors.Is(err, unix.ESRCH) {
118+
usage.CPUPartial = true
119+
}
120+
continue
118121
}
122+
queue = append(queue, read...)
119123
}
120124
return usage, true
121125
}

‎internal/processgroup/usage_darwin_walk_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,49 @@ func TestDarwinUsageMarksAnUnreadableParentPartial(t *testing.T) {
182182
t.Fatalf("a parent that exited: usage = %+v, %v, want 2 processes and a complete sum", usage, ok)
183183
}
184184
}
185+
186+
// A parent's pid and its listed child's pid can both change hands right after
187+
// the parent read the same again. The child read after that swap is a
188+
// stranger's busy child and must not be charged to the job: a child is
189+
// admitted only when its parent still reads as the same process after it.
190+
func TestDarwinUsageAdmitsAChildOnlyWhileItsParentIsTheSame(t *testing.T) {
191+
saved := usageReader
192+
t.Cleanup(func() { usageReader = saved })
193+
leader, own := task(10, 1, 10, 1), task(11, 10, 10, 1)
194+
strangerLeader, strangerChild := task(10, 1, 50, 0), task(11, 10, 50, 500)
195+
strangerLeader.Start, strangerChild.Start = 90000, 91000
196+
strangers := map[int]darwinproc.TaskInfo{12: task(12, 11, 50, 300), 13: task(13, 11, 50, 300)}
197+
leaderReads, swapped := 0, false
198+
usageReader.task = func(pid int) (darwinproc.TaskInfo, error) {
199+
switch {
200+
case pid == 10 && swapped:
201+
return strangerLeader, nil
202+
case pid == 10:
203+
// The swap lands just after the leader's second read.
204+
if leaderReads++; leaderReads == 2 {
205+
swapped = true
206+
}
207+
return leader, nil
208+
case pid == 11 && swapped:
209+
return strangerChild, nil
210+
case pid == 11:
211+
return own, nil
212+
case swapped && strangers[pid].PID == pid:
213+
return strangers[pid], nil
214+
}
215+
return darwinproc.TaskInfo{}, unix.ESRCH
216+
}
217+
usageReader.children = func(ppid int) ([]int, error) {
218+
switch {
219+
case ppid == 10:
220+
return []int{11}, nil
221+
case ppid == 11 && swapped:
222+
return []int{12, 13}, nil
223+
}
224+
return nil, nil
225+
}
226+
usage, ok := subtreeUsage(10)
227+
if !ok || usage.Processes != 2 || usage.CPUSeconds != 2 {
228+
t.Fatalf("usage = %+v, %v, want the leader and its own child only (2 processes, 2 CPU seconds)", usage, ok)
229+
}
230+
}

‎internal/session/jobbound_test.go‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,11 +235,13 @@ func TestASubtreeThatCannotBeReadIsNeverCut(t *testing.T) {
235235
// (macOS's walk limit, a truncated listing) still counts processes, but its CPU
236236
// sum is not comparable with the next one. A storm of sleepers read partially
237237
// for fourteen readings that then shrinks to a leader and one old child — whose
238-
// lifetime CPU the partial sums never held — must not be cut on that jump.
238+
// lifetime CPU the partial sums never held — must not be cut on that jump. The
239+
// storm starts after one complete, quiet reading, so the partial readings must
240+
// also drop that earlier baseline rather than leave it waiting for the jump.
239241
func TestAPartialCPUSumNeverMakesARate(t *testing.T) {
240242
registry := &jobRegistry{}
241243
one := &job{id: 1, kind: jobKindBash, state: jobRunning, done: make(chan struct{})}
242-
var readings []processgroup.Usage
244+
readings := []processgroup.Usage{{Processes: 2, CPUSeconds: 1}}
243245
for i := 0; i < jobBoundStrikes-1; i++ {
244246
readings = append(readings, processgroup.Usage{Processes: 1100, CPUSeconds: 1, CPUPartial: true})
245247
}

0 commit comments

Comments
 (0)