detached 75c7116

fix(mcp): always kill a reserved process group; treat /proc scan as advisory

commit
75c7116fab164aa370831e28c80a47ce9a8fa3d4
author
brice · · 2026-10-15 14:10 UTC
parent
1fb1dbf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +54 -11
M packages/agent/mcp/proc_unix.go +19 -9
@@ -42,9 +42,9 @@ type processGuard struct {
4242 leader int // pid of the group leader (== original pgid)
4343 proc *os.Process
4444 kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests
45- // zombie is set once the leader has exited but not been reaped. A probe
46- // with signal 0 cannot tell an empty group from one whose only member is
47- // that zombie, so liveness is then read from the process table.
45+ // zombie is set once the leader has exited but not been reaped. The pgid
46+ // is then reserved, so cleanup always ends with a group SIGKILL; the
47+ // process table is read only to end the SIGTERM grace period early.
4848 zombie bool
4949
5050 once sync.Once
@@ -115,12 +115,10 @@ func (g *processGuard) alive() bool {
115115 return false
116116 }
117117 if g.zombie {
118- if live, known := liveGroupMembers(g.pgid); known {
119- if !live {
120- g.pgid = 0
121- }
122- return live
123- }
118+ // A signal-0 probe would see the zombie leader. The /proc scan is
119+ // advisory only (not an atomic snapshot), so it never clears pgid.
120+ live, known := liveGroupMembers(g.pgid)
121+ return live || !known
124122 }
125123 err := g.kill(-g.pgid, 0)
126124 if errors.Is(err, syscall.ESRCH) {
@@ -144,6 +142,18 @@ func (g *processGuard) cleanup() {
144142 g.proc = nil
145143 g.mu.Unlock()
146144 }()
145+ g.mu.Lock()
146+ reserved := g.zombie
147+ g.mu.Unlock()
148+ if reserved {
149+ // The unreaped leader keeps the pgid ours: give members a grace
150+ // period, then kill the group unconditionally. The kernel delivers
151+ // a group signal to every member, including ones forked meanwhile.
152+ g.terminate(false)
153+ g.waitGone(300 * time.Millisecond)
154+ g.terminate(true)
155+ return
156+ }
147157 if !g.alive() {
148158 return
149159 }
@@ -42,9 +42,9 @@ type processGuard struct {
42 leader int // pid of the group leader (== original pgid)42 leader int // pid of the group leader (== original pgid)
43 proc *os.Process43 proc *os.Process
44 kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests44 kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests
45- // zombie is set once the leader has exited but not been reaped. A probe45+ // zombie is set once the leader has exited but not been reaped. The pgid
46- // with signal 0 cannot tell an empty group from one whose only member is46+ // is then reserved, so cleanup always ends with a group SIGKILL; the
47- // that zombie, so liveness is then read from the process table.47+ // process table is read only to end the SIGTERM grace period early.
48 zombie bool48 zombie bool
49 49
50 once sync.Once50 once sync.Once
@@ -115,12 +115,10 @@ func (g *processGuard) alive() bool {
115 return false115 return false
116 }116 }
117 if g.zombie {117 if g.zombie {
118- if live, known := liveGroupMembers(g.pgid); known {118+ // A signal-0 probe would see the zombie leader. The /proc scan is
119- if !live {119+ // advisory only (not an atomic snapshot), so it never clears pgid.
120- g.pgid = 0120+ live, known := liveGroupMembers(g.pgid)
121- }121+ return live || !known
122- return live
123- }
124 }122 }
125 err := g.kill(-g.pgid, 0)123 err := g.kill(-g.pgid, 0)
126 if errors.Is(err, syscall.ESRCH) {124 if errors.Is(err, syscall.ESRCH) {
@@ -144,6 +142,18 @@ func (g *processGuard) cleanup() {
144 g.proc = nil142 g.proc = nil
145 g.mu.Unlock()143 g.mu.Unlock()
146 }()144 }()
145+ g.mu.Lock()
146+ reserved := g.zombie
147+ g.mu.Unlock()
148+ if reserved {
149+ // The unreaped leader keeps the pgid ours: give members a grace
150+ // period, then kill the group unconditionally. The kernel delivers
151+ // a group signal to every member, including ones forked meanwhile.
152+ g.terminate(false)
153+ g.waitGone(300 * time.Millisecond)
154+ g.terminate(true)
155+ return
156+ }
147 if !g.alive() {157 if !g.alive() {
148 return158 return
149 }159 }
M packages/agent/mcp/proc_wait_linux.go +6 -2
@@ -26,7 +26,8 @@ func waitLeaderExited(pid int) bool {
2626 }
2727
2828 // liveGroupMembers reports whether any non-zombie process is in process group
29-// pgid, reading /proc. known is false when /proc cannot be read.
29+// pgid, reading /proc. known is false when /proc or a member's stat cannot be
30+// read. The scan is not atomic, so callers use it only as a hint.
3031 func liveGroupMembers(pgid int) (live, known bool) {
3132 entries, err := os.ReadDir("/proc")
3233 if err != nil {
@@ -39,9 +40,12 @@ func liveGroupMembers(pgid int) (live, known bool) {
3940 }
4041 sawProc = true
4142 raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
42- if err != nil {
43+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) {
4344 continue // exited meanwhile
4445 }
46+ if err != nil {
47+ return false, false
48+ }
4549 state, group, ok := parseProcStat(string(raw))
4650 if !ok || group != pgid || state == 'Z' || state == 'X' {
4751 continue
@@ -26,7 +26,8 @@ func waitLeaderExited(pid int) bool {
26 }26 }
27 27
28 // liveGroupMembers reports whether any non-zombie process is in process group28 // liveGroupMembers reports whether any non-zombie process is in process group
29-// pgid, reading /proc. known is false when /proc cannot be read.29+// pgid, reading /proc. known is false when /proc or a member's stat cannot be
30+// read. The scan is not atomic, so callers use it only as a hint.
30 func liveGroupMembers(pgid int) (live, known bool) {31 func liveGroupMembers(pgid int) (live, known bool) {
31 entries, err := os.ReadDir("/proc")32 entries, err := os.ReadDir("/proc")
32 if err != nil {33 if err != nil {
@@ -39,9 +40,12 @@ func liveGroupMembers(pgid int) (live, known bool) {
39 }40 }
40 sawProc = true41 sawProc = true
41 raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")42 raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
42- if err != nil {43+ if errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) {
43 continue // exited meanwhile44 continue // exited meanwhile
44 }45 }
46+ if err != nil {
47+ return false, false
48+ }
45 state, group, ok := parseProcStat(string(raw))49 state, group, ok := parseProcStat(string(raw))
46 if !ok || group != pgid || state == 'Z' || state == 'X' {50 if !ok || group != pgid || state == 'Z' || state == 'X' {
47 continue51 continue
M packages/agent/mcp/proc_wait_linux_test.go +29 -0
@@ -5,6 +5,8 @@ package mcp
55 import (
66 "os"
77 "strconv"
8+ "sync"
9+ "syscall"
810 "testing"
911 "time"
1012 )
@@ -109,3 +111,30 @@ func TestStdioGroupCleanedBeforeLeaderIsReaped(t *testing.T) {
109111 return os.IsNotExist(err)
110112 })
111113 }
114+
115+// While the leader is an unreaped zombie the pgid is reserved, so cleanup must
116+// end with a group SIGKILL even when the (non-atomic) /proc scan sees no live
117+// member: a descendant may have forked between the scan and its end.
118+func TestReservedGroupIsKilledDespiteEmptyScan(t *testing.T) {
119+ var mu sync.Mutex
120+ var sigs []syscall.Signal
121+ g := &processGuard{pgid: 1 << 30, leader: 1 << 30, zombie: true,
122+ kill: func(pid int, sig syscall.Signal) error {
123+ mu.Lock()
124+ defer mu.Unlock()
125+ if pid != -(1 << 30) {
126+ t.Errorf("signal to %d, want group", pid)
127+ }
128+ sigs = append(sigs, sig)
129+ return nil
130+ }}
131+ g.cleanup()
132+ mu.Lock()
133+ defer mu.Unlock()
134+ if len(sigs) == 0 || sigs[len(sigs)-1] != syscall.SIGKILL {
135+ t.Fatalf("signals = %v, want a final group SIGKILL", sigs)
136+ }
137+ if g.pgid != 0 {
138+ t.Error("pgid retained after cleanup")
139+ }
140+}
@@ -5,6 +5,8 @@ package mcp
5 import (5 import (
6 "os"6 "os"
7 "strconv"7 "strconv"
8+ "sync"
9+ "syscall"
8 "testing"10 "testing"
9 "time"11 "time"
10 )12 )
@@ -109,3 +111,30 @@ func TestStdioGroupCleanedBeforeLeaderIsReaped(t *testing.T) {
109 return os.IsNotExist(err)111 return os.IsNotExist(err)
110 })112 })
111 }113 }
114+
115+// While the leader is an unreaped zombie the pgid is reserved, so cleanup must
116+// end with a group SIGKILL even when the (non-atomic) /proc scan sees no live
117+// member: a descendant may have forked between the scan and its end.
118+func TestReservedGroupIsKilledDespiteEmptyScan(t *testing.T) {
119+ var mu sync.Mutex
120+ var sigs []syscall.Signal
121+ g := &processGuard{pgid: 1 << 30, leader: 1 << 30, zombie: true,
122+ kill: func(pid int, sig syscall.Signal) error {
123+ mu.Lock()
124+ defer mu.Unlock()
125+ if pid != -(1 << 30) {
126+ t.Errorf("signal to %d, want group", pid)
127+ }
128+ sigs = append(sigs, sig)
129+ return nil
130+ }}
131+ g.cleanup()
132+ mu.Lock()
133+ defer mu.Unlock()
134+ if len(sigs) == 0 || sigs[len(sigs)-1] != syscall.SIGKILL {
135+ t.Fatalf("signals = %v, want a final group SIGKILL", sigs)
136+ }
137+ if g.pgid != 0 {
138+ t.Error("pgid retained after cleanup")
139+ }
140+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close