detached 1fb1dbf

fix(mcp): clean process group before reaping leader; redact clipped HTTP bodies; mark clipped fields

commit
1fb1dbf210cf6ce9cece687603cff330193a82be
author
brice · · 2026-10-15 14:06 UTC
parent
ef9ee0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +525 -26
M packages/agent/mcp/http.go +28 -11
@@ -144,8 +144,11 @@ func (t *httpTransport) post(ctx context.Context, frame []byte) (*http.Response,
144144 }
145145 if resp.StatusCode >= 300 {
146146 defer resp.Body.Close()
147- snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
148- return nil, t.statusFailure(req, resp.StatusCode, snippet)
147+ // Read past the display limit by the longest secret's length, so a
148+ // secret straddling the cut is seen whole and can be redacted.
149+ limit := maxErrorBody + t.red.overlap()
150+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, int64(limit)))
151+ return nil, t.statusFailure(req, resp.StatusCode, snippet, len(snippet) >= limit)
149152 }
150153 return resp, nil
151154 }
@@ -154,8 +157,8 @@ func (t *httpTransport) post(ctx context.Context, frame []byte) (*http.Response,
154157 // request that carried a session id means the server forgot the session
155158 // (expired or restarted): the transport becomes unusable so the manager
156159 // reconnects with a fresh initialize. The failed call is not replayed.
157-func (t *httpTransport) statusFailure(req *http.Request, status int, body []byte) error {
158- err := t.red.statusError(status, body)
160+func (t *httpTransport) statusFailure(req *http.Request, status int, body []byte, truncated bool) error {
161+ err := t.red.statusErrorBody(status, body, truncated)
159162 if status == http.StatusNotFound && req.Header.Get(headerSessionID) != "" {
160163 err = fmt.Errorf("mcp session expired: %w", err)
161164 t.mu.Lock()
@@ -187,17 +190,31 @@ func httpStatusError(status int, body []byte) error {
187190 return (*redactor)(nil).statusError(status, body)
188191 }
189192
190-// statusError builds an HTTPStatusError whose body is redacted, stripped of
191-// terminal control sequences, and shortened for display.
193+// statusError builds an HTTPStatusError from a complete body.
192194 func (r *redactor) statusError(status int, body []byte) error {
193- // Redact before truncating so a secret cut at the limit is not half shown.
194- text := strings.TrimSpace(r.scrubOneLine(string(body)))
195- if len(text) > 300 {
196- cut := 300
195+ return r.statusErrorBody(status, body, false)
196+}
197+
198+// maxStatusBodyDisplay is how much of an error body is shown.
199+const maxStatusBodyDisplay = 300
200+
201+// statusErrorBody builds an HTTPStatusError whose body is redacted, stripped of
202+// terminal control sequences, and shortened for display. truncated says the
203+// body was cut by the reader at an arbitrary byte, so it may end inside a
204+// secret; whatever fragment of one remains at the very end is removed. The
205+// final display cut gets the same protection.
206+func (r *redactor) statusErrorBody(status int, body []byte, truncated bool) error {
207+ text := r.scrubOneLine(string(body))
208+ if truncated {
209+ text = r.trimPartialSecret(text)
210+ }
211+ text = strings.TrimSpace(text)
212+ if len(text) > maxStatusBodyDisplay {
213+ cut := maxStatusBodyDisplay
197214 for cut > 0 && !utf8.RuneStart(text[cut]) {
198215 cut--
199216 }
200- text = text[:cut] + "…"
217+ text = r.trimPartialSecret(text[:cut]) + "…"
201218 }
202219 return &HTTPStatusError{Status: status, Body: text}
203220 }
@@ -144,8 +144,11 @@ func (t *httpTransport) post(ctx context.Context, frame []byte) (*http.Response,
144 }144 }
145 if resp.StatusCode >= 300 {145 if resp.StatusCode >= 300 {
146 defer resp.Body.Close()146 defer resp.Body.Close()
147- snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))147+ // Read past the display limit by the longest secret's length, so a
148- return nil, t.statusFailure(req, resp.StatusCode, snippet)148+ // secret straddling the cut is seen whole and can be redacted.
149+ limit := maxErrorBody + t.red.overlap()
150+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, int64(limit)))
151+ return nil, t.statusFailure(req, resp.StatusCode, snippet, len(snippet) >= limit)
149 }152 }
150 return resp, nil153 return resp, nil
151 }154 }
@@ -154,8 +157,8 @@ func (t *httpTransport) post(ctx context.Context, frame []byte) (*http.Response,
154 // request that carried a session id means the server forgot the session157 // request that carried a session id means the server forgot the session
155 // (expired or restarted): the transport becomes unusable so the manager158 // (expired or restarted): the transport becomes unusable so the manager
156 // reconnects with a fresh initialize. The failed call is not replayed.159 // reconnects with a fresh initialize. The failed call is not replayed.
157-func (t *httpTransport) statusFailure(req *http.Request, status int, body []byte) error {160+func (t *httpTransport) statusFailure(req *http.Request, status int, body []byte, truncated bool) error {
158- err := t.red.statusError(status, body)161+ err := t.red.statusErrorBody(status, body, truncated)
159 if status == http.StatusNotFound && req.Header.Get(headerSessionID) != "" {162 if status == http.StatusNotFound && req.Header.Get(headerSessionID) != "" {
160 err = fmt.Errorf("mcp session expired: %w", err)163 err = fmt.Errorf("mcp session expired: %w", err)
161 t.mu.Lock()164 t.mu.Lock()
@@ -187,17 +190,31 @@ func httpStatusError(status int, body []byte) error {
187 return (*redactor)(nil).statusError(status, body)190 return (*redactor)(nil).statusError(status, body)
188 }191 }
189 192
190-// statusError builds an HTTPStatusError whose body is redacted, stripped of193+// statusError builds an HTTPStatusError from a complete body.
191-// terminal control sequences, and shortened for display.
192 func (r *redactor) statusError(status int, body []byte) error {194 func (r *redactor) statusError(status int, body []byte) error {
193- // Redact before truncating so a secret cut at the limit is not half shown.195+ return r.statusErrorBody(status, body, false)
194- text := strings.TrimSpace(r.scrubOneLine(string(body)))196+}
195- if len(text) > 300 {197+
196- cut := 300198+// maxStatusBodyDisplay is how much of an error body is shown.
199+const maxStatusBodyDisplay = 300
200+
201+// statusErrorBody builds an HTTPStatusError whose body is redacted, stripped of
202+// terminal control sequences, and shortened for display. truncated says the
203+// body was cut by the reader at an arbitrary byte, so it may end inside a
204+// secret; whatever fragment of one remains at the very end is removed. The
205+// final display cut gets the same protection.
206+func (r *redactor) statusErrorBody(status int, body []byte, truncated bool) error {
207+ text := r.scrubOneLine(string(body))
208+ if truncated {
209+ text = r.trimPartialSecret(text)
210+ }
211+ text = strings.TrimSpace(text)
212+ if len(text) > maxStatusBodyDisplay {
213+ cut := maxStatusBodyDisplay
197 for cut > 0 && !utf8.RuneStart(text[cut]) {214 for cut > 0 && !utf8.RuneStart(text[cut]) {
198 cut--215 cut--
199 }216 }
200- text = text[:cut] + "…"217+ text = r.trimPartialSecret(text[:cut]) + "…"
201 }218 }
202 return &HTTPStatusError{Status: status, Body: text}219 return &HTTPStatusError{Status: status, Body: text}
203 }220 }
M packages/agent/mcp/proc_unix.go +51 -10
@@ -19,17 +19,33 @@ func isolateProcess(cmd *exec.Cmd) {
1919
2020 // processGuard owns the process group of one stdio server.
2121 //
22-// A numeric process-group id is only safe to signal while the group still has
23-// members: once the last member is gone the kernel may hand the number to an
24-// unrelated process. The guard therefore (a) cleans the group exactly once,
25-// (b) forgets the id as soon as the group is observed empty or cleaned, and
26-// (c) is cleaned as soon as the direct child is reaped, so no stale id stays
27-// signalable for the life of the transport.
22+// A process-group id may be reused by an unrelated process as soon as no
23+// process uses the number any more. A group stays in use while ANY member
24+// exists, and that includes a zombie: the leader's pid (which is the pgid) is
25+// not freed until the leader is reaped. So the guard keeps the leader
26+// unreaped, as a zombie, until it has cleaned the group, wherever the platform
27+// can wait for a child without reaping it (waitLeaderExited; Linux). Only then
28+// is the leader reaped by cmd.Wait.
29+//
30+// Where that is not available (darwin, the BSDs) cleanup runs after the leader
31+// was reaped; the id is then only guaranteed to be ours while other group
32+// members live, which is the best that can be done and is documented as a
33+// residual race. In every case the guard
34+//
35+// (a) cleans the group exactly once,
36+// (b) forgets the id as soon as the group is observed empty or cleaned,
37+// (c) is cleaned as soon as the direct child exits, so no stale id stays
38+// signalable for the life of the transport.
2839 type processGuard struct {
29- mu sync.Mutex
30- pgid int // 0 once cleaned up or observed gone
31- proc *os.Process
32- kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests
40+ mu sync.Mutex
41+ pgid int // 0 once cleaned up or observed gone
42+ leader int // pid of the group leader (== original pgid)
43+ proc *os.Process
44+ 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.
48+ zombie bool
3349
3450 once sync.Once
3551 }
@@ -39,11 +55,28 @@ func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
3955 g := &processGuard{kill: func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) }}
4056 if cmd != nil && cmd.Process != nil {
4157 g.pgid = cmd.Process.Pid
58+ g.leader = cmd.Process.Pid
4259 g.proc = cmd.Process
4360 }
4461 return g, nil
4562 }
4663
64+// awaitLeaderExit blocks until the group leader has exited, without reaping
65+// it. It reports false when the platform cannot do that, in which case it
66+// returns immediately and the caller must Wait first.
67+func (g *processGuard) awaitLeaderExit() bool {
68+ if g == nil || g.leader == 0 {
69+ return false
70+ }
71+ if !waitLeaderExited(g.leader) {
72+ return false
73+ }
74+ g.mu.Lock()
75+ g.zombie = true
76+ g.mu.Unlock()
77+ return true
78+}
79+
4780 // terminate signals the whole group, including descendants that outlive the
4881 // direct child. It does nothing once the group is cleaned up.
4982 func (g *processGuard) terminate(force bool) {
@@ -81,6 +114,14 @@ func (g *processGuard) alive() bool {
81114 if g.pgid == 0 {
82115 return false
83116 }
117+ if g.zombie {
118+ if live, known := liveGroupMembers(g.pgid); known {
119+ if !live {
120+ g.pgid = 0
121+ }
122+ return live
123+ }
124+ }
84125 err := g.kill(-g.pgid, 0)
85126 if errors.Is(err, syscall.ESRCH) {
86127 g.pgid = 0
@@ -19,17 +19,33 @@ func isolateProcess(cmd *exec.Cmd) {
19 19
20 // processGuard owns the process group of one stdio server.20 // processGuard owns the process group of one stdio server.
21 //21 //
22-// A numeric process-group id is only safe to signal while the group still has22+// A process-group id may be reused by an unrelated process as soon as no
23-// members: once the last member is gone the kernel may hand the number to an23+// process uses the number any more. A group stays in use while ANY member
24-// unrelated process. The guard therefore (a) cleans the group exactly once,24+// exists, and that includes a zombie: the leader's pid (which is the pgid) is
25-// (b) forgets the id as soon as the group is observed empty or cleaned, and25+// not freed until the leader is reaped. So the guard keeps the leader
26-// (c) is cleaned as soon as the direct child is reaped, so no stale id stays26+// unreaped, as a zombie, until it has cleaned the group, wherever the platform
27-// signalable for the life of the transport.27+// can wait for a child without reaping it (waitLeaderExited; Linux). Only then
28+// is the leader reaped by cmd.Wait.
29+//
30+// Where that is not available (darwin, the BSDs) cleanup runs after the leader
31+// was reaped; the id is then only guaranteed to be ours while other group
32+// members live, which is the best that can be done and is documented as a
33+// residual race. In every case the guard
34+//
35+// (a) cleans the group exactly once,
36+// (b) forgets the id as soon as the group is observed empty or cleaned,
37+// (c) is cleaned as soon as the direct child exits, so no stale id stays
38+// signalable for the life of the transport.
28 type processGuard struct {39 type processGuard struct {
29- mu sync.Mutex40+ mu sync.Mutex
30- pgid int // 0 once cleaned up or observed gone41+ pgid int // 0 once cleaned up or observed gone
31- proc *os.Process42+ leader int // pid of the group leader (== original pgid)
32- kill func(pid int, sig syscall.Signal) error // syscall.Kill; replaced in tests43+ proc *os.Process
44+ 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.
48+ zombie bool
33 49
34 once sync.Once50 once sync.Once
35 }51 }
@@ -39,11 +55,28 @@ func newProcessGuard(cmd *exec.Cmd) (*processGuard, error) {
39 g := &processGuard{kill: func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) }}55 g := &processGuard{kill: func(pid int, sig syscall.Signal) error { return syscall.Kill(pid, sig) }}
40 if cmd != nil && cmd.Process != nil {56 if cmd != nil && cmd.Process != nil {
41 g.pgid = cmd.Process.Pid57 g.pgid = cmd.Process.Pid
58+ g.leader = cmd.Process.Pid
42 g.proc = cmd.Process59 g.proc = cmd.Process
43 }60 }
44 return g, nil61 return g, nil
45 }62 }
46 63
64+// awaitLeaderExit blocks until the group leader has exited, without reaping
65+// it. It reports false when the platform cannot do that, in which case it
66+// returns immediately and the caller must Wait first.
67+func (g *processGuard) awaitLeaderExit() bool {
68+ if g == nil || g.leader == 0 {
69+ return false
70+ }
71+ if !waitLeaderExited(g.leader) {
72+ return false
73+ }
74+ g.mu.Lock()
75+ g.zombie = true
76+ g.mu.Unlock()
77+ return true
78+}
79+
47 // terminate signals the whole group, including descendants that outlive the80 // terminate signals the whole group, including descendants that outlive the
48 // direct child. It does nothing once the group is cleaned up.81 // direct child. It does nothing once the group is cleaned up.
49 func (g *processGuard) terminate(force bool) {82 func (g *processGuard) terminate(force bool) {
@@ -81,6 +114,14 @@ func (g *processGuard) alive() bool {
81 if g.pgid == 0 {114 if g.pgid == 0 {
82 return false115 return false
83 }116 }
117+ if g.zombie {
118+ if live, known := liveGroupMembers(g.pgid); known {
119+ if !live {
120+ g.pgid = 0
121+ }
122+ return live
123+ }
124+ }
84 err := g.kill(-g.pgid, 0)125 err := g.kill(-g.pgid, 0)
85 if errors.Is(err, syscall.ESRCH) {126 if errors.Is(err, syscall.ESRCH) {
86 g.pgid = 0127 g.pgid = 0
A packages/agent/mcp/proc_wait_linux.go +71 -0
@@ -0,0 +1,71 @@
1+//go:build linux
2+
3+package mcp
4+
5+import (
6+ "errors"
7+ "os"
8+ "strconv"
9+ "strings"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// waitLeaderExited blocks until pid (our direct child) has exited, leaving it
15+// unreaped (a zombie) so its pid, and with it the process-group id, cannot be
16+// reused. It reports false if the wait failed.
17+func waitLeaderExited(pid int) bool {
18+ var info unix.Siginfo
19+ for {
20+ err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil)
21+ if errors.Is(err, unix.EINTR) {
22+ continue
23+ }
24+ return err == nil
25+ }
26+}
27+
28+// liveGroupMembers reports whether any non-zombie process is in process group
29+// pgid, reading /proc. known is false when /proc cannot be read.
30+func liveGroupMembers(pgid int) (live, known bool) {
31+ entries, err := os.ReadDir("/proc")
32+ if err != nil {
33+ return false, false
34+ }
35+ sawProc := false
36+ for _, e := range entries {
37+ if _, err := strconv.Atoi(e.Name()); err != nil {
38+ continue
39+ }
40+ sawProc = true
41+ raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
42+ if err != nil {
43+ continue // exited meanwhile
44+ }
45+ state, group, ok := parseProcStat(string(raw))
46+ if !ok || group != pgid || state == 'Z' || state == 'X' {
47+ continue
48+ }
49+ return true, true
50+ }
51+ return false, sawProc
52+}
53+
54+// parseProcStat extracts the state and process-group fields of a
55+// /proc/<pid>/stat line. The command name is parenthesised and may itself
56+// contain spaces and parentheses, so fields are counted after the last ')'.
57+func parseProcStat(stat string) (state byte, pgrp int, ok bool) {
58+ i := strings.LastIndexByte(stat, ')')
59+ if i < 0 {
60+ return 0, 0, false
61+ }
62+ fields := strings.Fields(stat[i+1:]) // state ppid pgrp ...
63+ if len(fields) < 3 || len(fields[0]) != 1 {
64+ return 0, 0, false
65+ }
66+ pg, err := strconv.Atoi(fields[2])
67+ if err != nil {
68+ return 0, 0, false
69+ }
70+ return fields[0][0], pg, true
71+}
@@ -0,0 +1,71 @@
1+//go:build linux
2+
3+package mcp
4+
5+import (
6+ "errors"
7+ "os"
8+ "strconv"
9+ "strings"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// waitLeaderExited blocks until pid (our direct child) has exited, leaving it
15+// unreaped (a zombie) so its pid, and with it the process-group id, cannot be
16+// reused. It reports false if the wait failed.
17+func waitLeaderExited(pid int) bool {
18+ var info unix.Siginfo
19+ for {
20+ err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil)
21+ if errors.Is(err, unix.EINTR) {
22+ continue
23+ }
24+ return err == nil
25+ }
26+}
27+
28+// liveGroupMembers reports whether any non-zombie process is in process group
29+// pgid, reading /proc. known is false when /proc cannot be read.
30+func liveGroupMembers(pgid int) (live, known bool) {
31+ entries, err := os.ReadDir("/proc")
32+ if err != nil {
33+ return false, false
34+ }
35+ sawProc := false
36+ for _, e := range entries {
37+ if _, err := strconv.Atoi(e.Name()); err != nil {
38+ continue
39+ }
40+ sawProc = true
41+ raw, err := os.ReadFile("/proc/" + e.Name() + "/stat")
42+ if err != nil {
43+ continue // exited meanwhile
44+ }
45+ state, group, ok := parseProcStat(string(raw))
46+ if !ok || group != pgid || state == 'Z' || state == 'X' {
47+ continue
48+ }
49+ return true, true
50+ }
51+ return false, sawProc
52+}
53+
54+// parseProcStat extracts the state and process-group fields of a
55+// /proc/<pid>/stat line. The command name is parenthesised and may itself
56+// contain spaces and parentheses, so fields are counted after the last ')'.
57+func parseProcStat(stat string) (state byte, pgrp int, ok bool) {
58+ i := strings.LastIndexByte(stat, ')')
59+ if i < 0 {
60+ return 0, 0, false
61+ }
62+ fields := strings.Fields(stat[i+1:]) // state ppid pgrp ...
63+ if len(fields) < 3 || len(fields[0]) != 1 {
64+ return 0, 0, false
65+ }
66+ pg, err := strconv.Atoi(fields[2])
67+ if err != nil {
68+ return 0, 0, false
69+ }
70+ return fields[0][0], pg, true
71+}
A packages/agent/mcp/proc_wait_linux_test.go +111 -0
@@ -0,0 +1,111 @@
1+//go:build linux
2+
3+package mcp
4+
5+import (
6+ "os"
7+ "strconv"
8+ "testing"
9+ "time"
10+)
11+
12+func after5s() <-chan time.Time { return time.After(5 * time.Second) }
13+
14+func TestParseProcStat(t *testing.T) {
15+ cases := []struct {
16+ name, in string
17+ state byte
18+ pgrp int
19+ ok bool
20+ }{
21+ {"plain", "123 (bash) S 1 123 123 0 -1 4194560", 'S', 123, true},
22+ {"zombie", "5 (fake-server) Z 1 77 77 0 -1 4", 'Z', 77, true},
23+ {"spaces and parens", "9 (a) b (c)) R 2 42 42 0", 'R', 42, true},
24+ {"truncated", "9 (x) S 1", 0, 0, false},
25+ {"no comm", "garbage", 0, 0, false},
26+ }
27+ for _, c := range cases {
28+ state, pg, ok := parseProcStat(c.in)
29+ if state != c.state || pg != c.pgrp || ok != c.ok {
30+ t.Errorf("%s: got (%c,%d,%v), want (%c,%d,%v)", c.name, state, pg, ok, c.state, c.pgrp, c.ok)
31+ }
32+ }
33+}
34+
35+func TestLiveGroupMembersSeesSelfOnly(t *testing.T) {
36+ // Our own process group has live members (us); a made-up one does not.
37+ live, known := liveGroupMembers(os.Getpid())
38+ if !known {
39+ t.Skip("/proc not readable")
40+ }
41+ _ = live // our pgid is usually not our pid; only the empty case is stable
42+ if live, known := liveGroupMembers(1 << 30); !known || live {
43+ t.Errorf("unused group reported live=%v known=%v", live, known)
44+ }
45+}
46+
47+// ---- GO-020: group cleanup happens while the leader is still a zombie ----
48+
49+func TestStdioGroupCleanedBeforeLeaderIsReaped(t *testing.T) {
50+ c, _ := connectFake(t, nil)
51+ st := c.t.(*stdioTransport)
52+ leader := st.cmd.Process.Pid
53+
54+ childPID, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { killPID(childPID) })
59+ if !processAlive(childPID) {
60+ t.Fatal("descendant not running")
61+ }
62+
63+ type observation struct {
64+ state byte
65+ childAlive bool
66+ pgidKept int
67+ }
68+ obs := make(chan observation, 1)
69+ hook := func(pid int) {
70+ if pid != leader {
71+ return
72+ }
73+ var o observation
74+ if raw, err := os.ReadFile("/proc/" + strconv.Itoa(leader) + "/stat"); err == nil {
75+ o.state, _, _ = parseProcStat(string(raw))
76+ }
77+ o.childAlive = processAlive(childPID)
78+ st.guard.mu.Lock()
79+ o.pgidKept = st.guard.pgid
80+ st.guard.mu.Unlock()
81+ select {
82+ case obs <- o:
83+ default:
84+ }
85+ }
86+ beforeReapHook.Store(&hook)
87+ t.Cleanup(func() { beforeReapHook.Store(nil) })
88+
89+ // The server exits on its own.
90+ _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
91+ var o observation
92+ select {
93+ case o = <-obs:
94+ case <-after5s():
95+ t.Fatal("cleanup hook never ran: the leader was reaped before group cleanup")
96+ }
97+ if o.state != 'Z' {
98+ t.Errorf("leader state at cleanup = %q, want Z (exited but not reaped, so its pid/pgid cannot be reused)", o.state)
99+ }
100+ if o.childAlive {
101+ t.Error("descendant still alive when the leader was about to be reaped")
102+ }
103+ if o.pgidKept != 0 {
104+ t.Errorf("process-group id %d retained after cleanup", o.pgidKept)
105+ }
106+ // And the leader is reaped afterwards.
107+ eventually(t, "leader to be reaped", func() bool {
108+ _, err := os.Stat("/proc/" + strconv.Itoa(leader))
109+ return os.IsNotExist(err)
110+ })
111+}
@@ -0,0 +1,111 @@
1+//go:build linux
2+
3+package mcp
4+
5+import (
6+ "os"
7+ "strconv"
8+ "testing"
9+ "time"
10+)
11+
12+func after5s() <-chan time.Time { return time.After(5 * time.Second) }
13+
14+func TestParseProcStat(t *testing.T) {
15+ cases := []struct {
16+ name, in string
17+ state byte
18+ pgrp int
19+ ok bool
20+ }{
21+ {"plain", "123 (bash) S 1 123 123 0 -1 4194560", 'S', 123, true},
22+ {"zombie", "5 (fake-server) Z 1 77 77 0 -1 4", 'Z', 77, true},
23+ {"spaces and parens", "9 (a) b (c)) R 2 42 42 0", 'R', 42, true},
24+ {"truncated", "9 (x) S 1", 0, 0, false},
25+ {"no comm", "garbage", 0, 0, false},
26+ }
27+ for _, c := range cases {
28+ state, pg, ok := parseProcStat(c.in)
29+ if state != c.state || pg != c.pgrp || ok != c.ok {
30+ t.Errorf("%s: got (%c,%d,%v), want (%c,%d,%v)", c.name, state, pg, ok, c.state, c.pgrp, c.ok)
31+ }
32+ }
33+}
34+
35+func TestLiveGroupMembersSeesSelfOnly(t *testing.T) {
36+ // Our own process group has live members (us); a made-up one does not.
37+ live, known := liveGroupMembers(os.Getpid())
38+ if !known {
39+ t.Skip("/proc not readable")
40+ }
41+ _ = live // our pgid is usually not our pid; only the empty case is stable
42+ if live, known := liveGroupMembers(1 << 30); !known || live {
43+ t.Errorf("unused group reported live=%v known=%v", live, known)
44+ }
45+}
46+
47+// ---- GO-020: group cleanup happens while the leader is still a zombie ----
48+
49+func TestStdioGroupCleanedBeforeLeaderIsReaped(t *testing.T) {
50+ c, _ := connectFake(t, nil)
51+ st := c.t.(*stdioTransport)
52+ leader := st.cmd.Process.Pid
53+
54+ childPID, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"sleeper-stubborn"}`))
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { killPID(childPID) })
59+ if !processAlive(childPID) {
60+ t.Fatal("descendant not running")
61+ }
62+
63+ type observation struct {
64+ state byte
65+ childAlive bool
66+ pgidKept int
67+ }
68+ obs := make(chan observation, 1)
69+ hook := func(pid int) {
70+ if pid != leader {
71+ return
72+ }
73+ var o observation
74+ if raw, err := os.ReadFile("/proc/" + strconv.Itoa(leader) + "/stat"); err == nil {
75+ o.state, _, _ = parseProcStat(string(raw))
76+ }
77+ o.childAlive = processAlive(childPID)
78+ st.guard.mu.Lock()
79+ o.pgidKept = st.guard.pgid
80+ st.guard.mu.Unlock()
81+ select {
82+ case obs <- o:
83+ default:
84+ }
85+ }
86+ beforeReapHook.Store(&hook)
87+ t.Cleanup(func() { beforeReapHook.Store(nil) })
88+
89+ // The server exits on its own.
90+ _, _ = c.CallTool(t.Context(), "exit", []byte(`{}`))
91+ var o observation
92+ select {
93+ case o = <-obs:
94+ case <-after5s():
95+ t.Fatal("cleanup hook never ran: the leader was reaped before group cleanup")
96+ }
97+ if o.state != 'Z' {
98+ t.Errorf("leader state at cleanup = %q, want Z (exited but not reaped, so its pid/pgid cannot be reused)", o.state)
99+ }
100+ if o.childAlive {
101+ t.Error("descendant still alive when the leader was about to be reaped")
102+ }
103+ if o.pgidKept != 0 {
104+ t.Errorf("process-group id %d retained after cleanup", o.pgidKept)
105+ }
106+ // And the leader is reaped afterwards.
107+ eventually(t, "leader to be reaped", func() bool {
108+ _, err := os.Stat("/proc/" + strconv.Itoa(leader))
109+ return os.IsNotExist(err)
110+ })
111+}
A packages/agent/mcp/proc_wait_other.go +10 -0
@@ -0,0 +1,10 @@
1+//go:build !linux && !windows
2+
3+package mcp
4+
5+// waitLeaderExited is unavailable here: the portable APIs reap the child. The
6+// guard then cleans up after the leader was reaped (see processGuard).
7+func waitLeaderExited(int) bool { return false }
8+
9+// liveGroupMembers cannot inspect the process table portably.
10+func liveGroupMembers(int) (live, known bool) { return false, false }
@@ -0,0 +1,10 @@
1+//go:build !linux && !windows
2+
3+package mcp
4+
5+// waitLeaderExited is unavailable here: the portable APIs reap the child. The
6+// guard then cleans up after the leader was reaped (see processGuard).
7+func waitLeaderExited(int) bool { return false }
8+
9+// liveGroupMembers cannot inspect the process table portably.
10+func liveGroupMembers(int) (live, known bool) { return false, false }
M packages/agent/mcp/proc_windows.go +5 -0
@@ -112,6 +112,11 @@ func resumeProcess(pid uint32) error {
112112 return nil
113113 }
114114
115+// awaitLeaderExit is not needed on Windows: the Job Object, not a reusable
116+// numeric id, identifies the group. It reports false so the caller waits for
117+// the process first.
118+func (g *processGuard) awaitLeaderExit() bool { return false }
119+
115120 // terminate kills every process in the job. Windows has no graceful group
116121 // signal, so force is ignored.
117122 func (g *processGuard) terminate(_ bool) {
@@ -112,6 +112,11 @@ func resumeProcess(pid uint32) error {
112 return nil112 return nil
113 }113 }
114 114
115+// awaitLeaderExit is not needed on Windows: the Job Object, not a reusable
116+// numeric id, identifies the group. It reports false so the caller waits for
117+// the process first.
118+func (g *processGuard) awaitLeaderExit() bool { return false }
119+
115 // terminate kills every process in the job. Windows has no graceful group120 // terminate kills every process in the job. Windows has no graceful group
116 // signal, so force is ignored.121 // signal, so force is ignored.
117 func (g *processGuard) terminate(_ bool) {122 func (g *processGuard) terminate(_ bool) {
A packages/agent/mcp/round3_test.go +183 -0
@@ -0,0 +1,183 @@
1+package mcp
2+
3+import (
4+ "context"
5+ "errors"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+)
13+
14+// Regression tests for the third review round (platform-independent parts).
15+
16+// ---- GO-004: HTTP error bodies ----
17+
18+func TestHTTPErrorBodySecretStraddlingDisplayCut(t *testing.T) {
19+ // End to end: secrets straddling the display cut and split by escapes.
20+ const secret = "HTTPSECRET-0123456789abcdef"
21+ t.Setenv("MCP_T_BEARER", secret)
22+ // The secret straddles the 300-byte display cut, and a second copy is
23+ // split by escape sequences so it only matches after stripping.
24+ body := strings.Repeat("x", maxStatusBodyDisplay-len(secret)/2) + secret + strings.Repeat("y", 2000) +
25+ "HTTPSE\x1b[0mCRET-0123456789abcdef"
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ http.Error(w, body, http.StatusInternalServerError)
28+ }))
29+ defer srv.Close()
30+ cfg := ServerConfig{Type: "http", URL: srv.URL, BearerTokenEnvVar: "MCP_T_BEARER"}
31+ tr, err := newHTTPTransport("x", cfg, nil)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ defer tr.close()
36+ if got := tr.red.overlap(); got < len(secret) {
37+ t.Fatalf("overlap %d does not cover the secret length %d", got, len(secret))
38+ }
39+ _, err = tr.request(context.Background(), 1, "tools/list", nil)
40+ if err == nil {
41+ t.Fatal("want an error")
42+ }
43+ for _, frag := range []string{secret, "HTTPSECR", "HTTPS", "SECRET"} {
44+ if strings.Contains(err.Error(), frag) {
45+ t.Errorf("secret fragment %q leaked: %q", frag, err)
46+ }
47+ }
48+}
49+
50+func TestStatusErrorBodyTrimsPartialSecrets(t *testing.T) {
51+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET-99"}})
52+ // Body cut by the reader in the middle of the secret.
53+ err := red.statusErrorBody(500, []byte("failed with token TOPSECR"), true)
54+ if strings.Contains(err.Error(), "TOP") {
55+ t.Errorf("reader-cut prefix survived: %q", err)
56+ }
57+ // The same text, complete: a fragment that is not the secret stays.
58+ err = red.statusErrorBody(500, []byte("failed with token TOPSECR"), false)
59+ if !strings.Contains(err.Error(), "TOPSECR") {
60+ t.Errorf("complete bodies must not be trimmed: %q", err)
61+ }
62+ // A secret hidden by control sequences is matched after stripping, so the
63+ // 300-byte display cut never lands inside it. (The trim after that cut is
64+ // defense in depth: redaction runs first, so a whole secret is already the
65+ // marker when the cut happens and no behavioural input reaches the trim.)
66+ long := strings.Repeat("a", 296) + "TOPS" + "\x1b[0mECRET-99" + strings.Repeat("b", 50)
67+ err = red.statusErrorBody(500, []byte(long), false)
68+ if strings.Contains(err.Error(), "TOPS") || strings.Contains(err.Error(), "SECRET") {
69+ t.Errorf("display cut exposed a secret prefix: %q", err)
70+ }
71+ if !strings.Contains(err.Error(), "…") {
72+ t.Errorf("long body not marked cut: %q", err)
73+ }
74+ if n := len(err.(*HTTPStatusError).Body); n > maxStatusBodyDisplay+len("…") {
75+ t.Errorf("body not bounded: %d", n)
76+ }
77+}
78+
79+// ---- GO-021: writer-side withdrawal ----
80+
81+// lateCtx reports an error from Err before Done closes, which lets a test
82+// order the writer's and the caller's observations of the cancellation.
83+type lateCtx struct {
84+ context.Context
85+ done chan struct{}
86+ err atomic.Bool
87+}
88+
89+func newLateCtx() *lateCtx { return &lateCtx{Context: context.Background(), done: make(chan struct{})} }
90+
91+func (c *lateCtx) Done() <-chan struct{} { return c.done }
92+func (c *lateCtx) Err() error {
93+ if c.err.Load() {
94+ return context.Canceled
95+ }
96+ return nil
97+}
98+
99+func TestWriteFrameWriterSideSkipDoesNotFailTransport(t *testing.T) {
100+ c, _ := connectFake(t, nil)
101+ st := c.t.(*stdioTransport)
102+ ctx := newLateCtx()
103+ ctx.err.Store(true) // the writer will see an ended context at dequeue...
104+
105+ skipped := make(chan struct{})
106+ release := make(chan struct{})
107+ st.hookSkipped = func() {
108+ close(ctx.done) // ...and only now does the caller learn of it
109+ close(skipped)
110+ <-release // keep the writer from reporting, so the caller takes the Done branch
111+ }
112+ type result struct {
113+ sent bool
114+ err error
115+ }
116+ res := make(chan result, 1)
117+ go func() {
118+ sent, err := st.writeFrame(ctx, []byte(`{"jsonrpc":"2.0","method":"notifications/x"}`))
119+ res <- result{sent, err}
120+ }()
121+ select {
122+ case <-skipped:
123+ case <-time.After(5 * time.Second):
124+ t.Fatal("writer never skipped the frame")
125+ }
126+ var r result
127+ select {
128+ case r = <-res:
129+ case <-time.After(5 * time.Second):
130+ t.Fatal("caller did not return")
131+ }
132+ close(release)
133+ if r.sent || !errors.Is(r.err, context.Canceled) {
134+ t.Errorf("sent=%v err=%v, want withdrawn", r.sent, r.err)
135+ }
136+ select {
137+ case <-st.done():
138+ t.Fatalf("a frame withdrawn by the writer failed the transport: %v", st.failure())
139+ default:
140+ }
141+ // The transport still works.
142+ if got := callText(t, c, "echo", `{"text":"ok"}`); got != "echo: ok" {
143+ t.Errorf("after skip: %q", got)
144+ }
145+}
146+
147+// ---- GO-022: budget marker after rune rounding ----
148+
149+func TestBudgetedFieldMarksTruncationAfterRuneRounding(t *testing.T) {
150+ b := &budgetedText{}
151+ b.write(strings.Repeat("a", maxResultTextBytes-4)) // remaining = 4
152+ // remaining+1 = 5 bytes lands inside the second 3-byte rune; rounding
153+ // down gives 3 bytes, which is within budget, yet text was dropped.
154+ got := b.field("日日日日")
155+ if got != "日" {
156+ t.Fatalf("field = %q", got)
157+ }
158+ if !b.clipped {
159+ t.Fatal("clipping not recorded")
160+ }
161+ b.write("- " + got)
162+ if !b.full() {
163+ t.Error("truncation flag not set although text was clipped")
164+ }
165+ if !strings.HasSuffix(b.String(), "[truncated at 51200 bytes]") {
166+ t.Errorf("marker missing: %q", b.String()[len(b.String())-40:])
167+ }
168+ // Later writes are ignored.
169+ before := b.String()
170+ b.write("more")
171+ if b.String() != before {
172+ t.Error("write accepted after truncation")
173+ }
174+ // A field that fits is neither clipped nor marked.
175+ c := &budgetedText{}
176+ if got := c.field("日本"); got != "日本" || c.clipped {
177+ t.Errorf("field = %q clipped=%v", got, c.clipped)
178+ }
179+ c.write("- 日本\n")
180+ if c.full() || strings.Contains(c.String(), "truncated") {
181+ t.Error("unclipped field must not be marked")
182+ }
183+}
@@ -0,0 +1,183 @@
1+package mcp
2+
3+import (
4+ "context"
5+ "errors"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+)
13+
14+// Regression tests for the third review round (platform-independent parts).
15+
16+// ---- GO-004: HTTP error bodies ----
17+
18+func TestHTTPErrorBodySecretStraddlingDisplayCut(t *testing.T) {
19+ // End to end: secrets straddling the display cut and split by escapes.
20+ const secret = "HTTPSECRET-0123456789abcdef"
21+ t.Setenv("MCP_T_BEARER", secret)
22+ // The secret straddles the 300-byte display cut, and a second copy is
23+ // split by escape sequences so it only matches after stripping.
24+ body := strings.Repeat("x", maxStatusBodyDisplay-len(secret)/2) + secret + strings.Repeat("y", 2000) +
25+ "HTTPSE\x1b[0mCRET-0123456789abcdef"
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ http.Error(w, body, http.StatusInternalServerError)
28+ }))
29+ defer srv.Close()
30+ cfg := ServerConfig{Type: "http", URL: srv.URL, BearerTokenEnvVar: "MCP_T_BEARER"}
31+ tr, err := newHTTPTransport("x", cfg, nil)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ defer tr.close()
36+ if got := tr.red.overlap(); got < len(secret) {
37+ t.Fatalf("overlap %d does not cover the secret length %d", got, len(secret))
38+ }
39+ _, err = tr.request(context.Background(), 1, "tools/list", nil)
40+ if err == nil {
41+ t.Fatal("want an error")
42+ }
43+ for _, frag := range []string{secret, "HTTPSECR", "HTTPS", "SECRET"} {
44+ if strings.Contains(err.Error(), frag) {
45+ t.Errorf("secret fragment %q leaked: %q", frag, err)
46+ }
47+ }
48+}
49+
50+func TestStatusErrorBodyTrimsPartialSecrets(t *testing.T) {
51+ red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET-99"}})
52+ // Body cut by the reader in the middle of the secret.
53+ err := red.statusErrorBody(500, []byte("failed with token TOPSECR"), true)
54+ if strings.Contains(err.Error(), "TOP") {
55+ t.Errorf("reader-cut prefix survived: %q", err)
56+ }
57+ // The same text, complete: a fragment that is not the secret stays.
58+ err = red.statusErrorBody(500, []byte("failed with token TOPSECR"), false)
59+ if !strings.Contains(err.Error(), "TOPSECR") {
60+ t.Errorf("complete bodies must not be trimmed: %q", err)
61+ }
62+ // A secret hidden by control sequences is matched after stripping, so the
63+ // 300-byte display cut never lands inside it. (The trim after that cut is
64+ // defense in depth: redaction runs first, so a whole secret is already the
65+ // marker when the cut happens and no behavioural input reaches the trim.)
66+ long := strings.Repeat("a", 296) + "TOPS" + "\x1b[0mECRET-99" + strings.Repeat("b", 50)
67+ err = red.statusErrorBody(500, []byte(long), false)
68+ if strings.Contains(err.Error(), "TOPS") || strings.Contains(err.Error(), "SECRET") {
69+ t.Errorf("display cut exposed a secret prefix: %q", err)
70+ }
71+ if !strings.Contains(err.Error(), "…") {
72+ t.Errorf("long body not marked cut: %q", err)
73+ }
74+ if n := len(err.(*HTTPStatusError).Body); n > maxStatusBodyDisplay+len("…") {
75+ t.Errorf("body not bounded: %d", n)
76+ }
77+}
78+
79+// ---- GO-021: writer-side withdrawal ----
80+
81+// lateCtx reports an error from Err before Done closes, which lets a test
82+// order the writer's and the caller's observations of the cancellation.
83+type lateCtx struct {
84+ context.Context
85+ done chan struct{}
86+ err atomic.Bool
87+}
88+
89+func newLateCtx() *lateCtx { return &lateCtx{Context: context.Background(), done: make(chan struct{})} }
90+
91+func (c *lateCtx) Done() <-chan struct{} { return c.done }
92+func (c *lateCtx) Err() error {
93+ if c.err.Load() {
94+ return context.Canceled
95+ }
96+ return nil
97+}
98+
99+func TestWriteFrameWriterSideSkipDoesNotFailTransport(t *testing.T) {
100+ c, _ := connectFake(t, nil)
101+ st := c.t.(*stdioTransport)
102+ ctx := newLateCtx()
103+ ctx.err.Store(true) // the writer will see an ended context at dequeue...
104+
105+ skipped := make(chan struct{})
106+ release := make(chan struct{})
107+ st.hookSkipped = func() {
108+ close(ctx.done) // ...and only now does the caller learn of it
109+ close(skipped)
110+ <-release // keep the writer from reporting, so the caller takes the Done branch
111+ }
112+ type result struct {
113+ sent bool
114+ err error
115+ }
116+ res := make(chan result, 1)
117+ go func() {
118+ sent, err := st.writeFrame(ctx, []byte(`{"jsonrpc":"2.0","method":"notifications/x"}`))
119+ res <- result{sent, err}
120+ }()
121+ select {
122+ case <-skipped:
123+ case <-time.After(5 * time.Second):
124+ t.Fatal("writer never skipped the frame")
125+ }
126+ var r result
127+ select {
128+ case r = <-res:
129+ case <-time.After(5 * time.Second):
130+ t.Fatal("caller did not return")
131+ }
132+ close(release)
133+ if r.sent || !errors.Is(r.err, context.Canceled) {
134+ t.Errorf("sent=%v err=%v, want withdrawn", r.sent, r.err)
135+ }
136+ select {
137+ case <-st.done():
138+ t.Fatalf("a frame withdrawn by the writer failed the transport: %v", st.failure())
139+ default:
140+ }
141+ // The transport still works.
142+ if got := callText(t, c, "echo", `{"text":"ok"}`); got != "echo: ok" {
143+ t.Errorf("after skip: %q", got)
144+ }
145+}
146+
147+// ---- GO-022: budget marker after rune rounding ----
148+
149+func TestBudgetedFieldMarksTruncationAfterRuneRounding(t *testing.T) {
150+ b := &budgetedText{}
151+ b.write(strings.Repeat("a", maxResultTextBytes-4)) // remaining = 4
152+ // remaining+1 = 5 bytes lands inside the second 3-byte rune; rounding
153+ // down gives 3 bytes, which is within budget, yet text was dropped.
154+ got := b.field("日日日日")
155+ if got != "日" {
156+ t.Fatalf("field = %q", got)
157+ }
158+ if !b.clipped {
159+ t.Fatal("clipping not recorded")
160+ }
161+ b.write("- " + got)
162+ if !b.full() {
163+ t.Error("truncation flag not set although text was clipped")
164+ }
165+ if !strings.HasSuffix(b.String(), "[truncated at 51200 bytes]") {
166+ t.Errorf("marker missing: %q", b.String()[len(b.String())-40:])
167+ }
168+ // Later writes are ignored.
169+ before := b.String()
170+ b.write("more")
171+ if b.String() != before {
172+ t.Error("write accepted after truncation")
173+ }
174+ // A field that fits is neither clipped nor marked.
175+ c := &budgetedText{}
176+ if got := c.field("日本"); got != "日本" || c.clipped {
177+ t.Errorf("field = %q clipped=%v", got, c.clipped)
178+ }
179+ c.write("- 日本\n")
180+ if c.full() || strings.Contains(c.String(), "truncated") {
181+ t.Error("unclipped field must not be marked")
182+ }
183+}
M packages/agent/mcp/sanitize.go +9 -0
@@ -163,6 +163,15 @@ func (r *redactor) scrub(s string) string {
163163 return scrubURLs(s)
164164 }
165165
166+// overlap is how many extra bytes to read past a display limit so a secret
167+// straddling the limit is captured whole. It is bounded by maxStderrOverlap.
168+func (r *redactor) overlap() int {
169+ if r == nil {
170+ return 0
171+ }
172+ return min(r.maxLen, maxStderrOverlap)
173+}
174+
166175 // redact replaces configured secret values. A nil redactor changes nothing.
167176 func (r *redactor) redact(s string) string {
168177 if r == nil {
@@ -163,6 +163,15 @@ func (r *redactor) scrub(s string) string {
163 return scrubURLs(s)163 return scrubURLs(s)
164 }164 }
165 165
166+// overlap is how many extra bytes to read past a display limit so a secret
167+// straddling the limit is captured whole. It is bounded by maxStderrOverlap.
168+func (r *redactor) overlap() int {
169+ if r == nil {
170+ return 0
171+ }
172+ return min(r.maxLen, maxStderrOverlap)
173+}
174+
166 // redact replaces configured secret values. A nil redactor changes nothing.175 // redact replaces configured secret values. A nil redactor changes nothing.
167 func (r *redactor) redact(s string) string {176 func (r *redactor) redact(s string) string {
168 if r == nil {177 if r == nil {
M packages/agent/mcp/stdio.go +35 -3
@@ -56,6 +56,10 @@ var defaultEnvVars = func() []string {
5656 }
5757 }()
5858
59+// beforeReapHook, when set (tests), runs after the group was cleaned and
60+// before the leader is reaped.
61+var beforeReapHook atomic.Pointer[func(pid int)]
62+
5963 // writeReq is one frame queued for the writer goroutine.
6064 type writeReq struct {
6165 ctx context.Context
@@ -105,6 +109,11 @@ type stdioTransport struct {
105109 // cancelsSent counts notifications/cancelled sends started (diagnostics
106110 // and tests).
107111 cancelsSent atomic.Int64
112+ // Test hooks for the writer (nil in production): hookDequeued runs when the
113+ // writer takes a frame off the queue, hookSkipped after it claimed a frame
114+ // whose context had ended and before it reports that to the caller.
115+ hookDequeued func()
116+ hookSkipped func()
108117 // writesStarted counts frames the writer has begun writing (diagnostics
109118 // and tests).
110119 writesStarted atomic.Int64
@@ -201,6 +210,16 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
201210 go t.readStderr(errR)
202211 go t.readLoop(outR)
203212 go func() {
213+ // Wait for the leader to exit WITHOUT reaping it, clean the group
214+ // while the leader is a zombie (its pid, the pgid, cannot be reused
215+ // yet), and only then reap it.
216+ leaderGone := t.guard.awaitLeaderExit()
217+ if leaderGone {
218+ t.guard.cleanup()
219+ if h := beforeReapHook.Load(); h != nil {
220+ (*h)(cmd.Process.Pid)
221+ }
222+ }
204223 waitErr := cmd.Wait()
205224 close(t.exited)
206225 if waitErr == nil {
@@ -213,9 +232,10 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
213232 case <-time.After(250 * time.Millisecond):
214233 }
215234 t.fail(fmt.Errorf("server process ended: %w", waitErr))
216- // The leader is gone: stop whatever it left behind, once, while the
217- // process-group id is still guaranteed to belong to our group.
218- t.guard.cleanup()
235+ if !leaderGone {
236+ // No wait-without-reap on this platform: clean up after the fact.
237+ t.guard.cleanup()
238+ }
219239 }()
220240 return t, nil
221241 }
@@ -377,7 +397,13 @@ func (t *stdioTransport) writeLoop() {
377397 case <-t.doneCh:
378398 return
379399 case req := <-t.writeQ:
400+ if t.hookDequeued != nil {
401+ t.hookDequeued()
402+ }
380403 if err := req.ctx.Err(); err != nil && req.state.CompareAndSwap(reqQueued, reqSkipped) {
404+ if t.hookSkipped != nil {
405+ t.hookSkipped()
406+ }
381407 req.res <- err // never started: nothing partial on the wire
382408 continue
383409 }
@@ -427,6 +453,12 @@ func (t *stdioTransport) writeFrame(ctx context.Context, frame []byte) (sent boo
427453 if req.state.CompareAndSwap(reqQueued, reqSkipped) {
428454 return false, ctx.Err()
429455 }
456+ if req.state.Load() == reqSkipped {
457+ // The writer already withdrew the frame itself (its context had
458+ // ended when it was dequeued): nothing was written, so the
459+ // transport is untouched.
460+ return false, ctx.Err()
461+ }
430462 select {
431463 case err := <-req.res:
432464 return req.state.Load() == reqWriting, err
@@ -56,6 +56,10 @@ var defaultEnvVars = func() []string {
56 }56 }
57 }()57 }()
58 58
59+// beforeReapHook, when set (tests), runs after the group was cleaned and
60+// before the leader is reaped.
61+var beforeReapHook atomic.Pointer[func(pid int)]
62+
59 // writeReq is one frame queued for the writer goroutine.63 // writeReq is one frame queued for the writer goroutine.
60 type writeReq struct {64 type writeReq struct {
61 ctx context.Context65 ctx context.Context
@@ -105,6 +109,11 @@ type stdioTransport struct {
105 // cancelsSent counts notifications/cancelled sends started (diagnostics109 // cancelsSent counts notifications/cancelled sends started (diagnostics
106 // and tests).110 // and tests).
107 cancelsSent atomic.Int64111 cancelsSent atomic.Int64
112+ // Test hooks for the writer (nil in production): hookDequeued runs when the
113+ // writer takes a frame off the queue, hookSkipped after it claimed a frame
114+ // whose context had ended and before it reports that to the caller.
115+ hookDequeued func()
116+ hookSkipped func()
108 // writesStarted counts frames the writer has begun writing (diagnostics117 // writesStarted counts frames the writer has begun writing (diagnostics
109 // and tests).118 // and tests).
110 writesStarted atomic.Int64119 writesStarted atomic.Int64
@@ -201,6 +210,16 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
201 go t.readStderr(errR)210 go t.readStderr(errR)
202 go t.readLoop(outR)211 go t.readLoop(outR)
203 go func() {212 go func() {
213+ // Wait for the leader to exit WITHOUT reaping it, clean the group
214+ // while the leader is a zombie (its pid, the pgid, cannot be reused
215+ // yet), and only then reap it.
216+ leaderGone := t.guard.awaitLeaderExit()
217+ if leaderGone {
218+ t.guard.cleanup()
219+ if h := beforeReapHook.Load(); h != nil {
220+ (*h)(cmd.Process.Pid)
221+ }
222+ }
204 waitErr := cmd.Wait()223 waitErr := cmd.Wait()
205 close(t.exited)224 close(t.exited)
206 if waitErr == nil {225 if waitErr == nil {
@@ -213,9 +232,10 @@ func startStdio(name string, cfg ServerConfig, cwd string) (*stdioTransport, err
213 case <-time.After(250 * time.Millisecond):232 case <-time.After(250 * time.Millisecond):
214 }233 }
215 t.fail(fmt.Errorf("server process ended: %w", waitErr))234 t.fail(fmt.Errorf("server process ended: %w", waitErr))
216- // The leader is gone: stop whatever it left behind, once, while the235+ if !leaderGone {
217- // process-group id is still guaranteed to belong to our group.236+ // No wait-without-reap on this platform: clean up after the fact.
218- t.guard.cleanup()237+ t.guard.cleanup()
238+ }
219 }()239 }()
220 return t, nil240 return t, nil
221 }241 }
@@ -377,7 +397,13 @@ func (t *stdioTransport) writeLoop() {
377 case <-t.doneCh:397 case <-t.doneCh:
378 return398 return
379 case req := <-t.writeQ:399 case req := <-t.writeQ:
400+ if t.hookDequeued != nil {
401+ t.hookDequeued()
402+ }
380 if err := req.ctx.Err(); err != nil && req.state.CompareAndSwap(reqQueued, reqSkipped) {403 if err := req.ctx.Err(); err != nil && req.state.CompareAndSwap(reqQueued, reqSkipped) {
404+ if t.hookSkipped != nil {
405+ t.hookSkipped()
406+ }
381 req.res <- err // never started: nothing partial on the wire407 req.res <- err // never started: nothing partial on the wire
382 continue408 continue
383 }409 }
@@ -427,6 +453,12 @@ func (t *stdioTransport) writeFrame(ctx context.Context, frame []byte) (sent boo
427 if req.state.CompareAndSwap(reqQueued, reqSkipped) {453 if req.state.CompareAndSwap(reqQueued, reqSkipped) {
428 return false, ctx.Err()454 return false, ctx.Err()
429 }455 }
456+ if req.state.Load() == reqSkipped {
457+ // The writer already withdrew the frame itself (its context had
458+ // ended when it was dequeued): nothing was written, so the
459+ // transport is untouched.
460+ return false, ctx.Err()
461+ }
430 select {462 select {
431 case err := <-req.res:463 case err := <-req.res:
432 return req.state.Load() == reqWriting, err464 return req.state.Load() == reqWriting, err
M packages/agent/mcp/tools.go +22 -2
@@ -111,6 +111,13 @@ func (b *textBudget) take(s string) string {
111111 return s[:keep] + fmt.Sprintf("\n... [truncated at %d bytes]", maxResultTextBytes)
112112 }
113113
114+// forceTruncate ends the budget now and returns the truncation marker.
115+func (b *textBudget) forceTruncate() string {
116+ b.used = maxResultTextBytes
117+ b.truncated = true
118+ return fmt.Sprintf("\n... [truncated at %d bytes]", maxResultTextBytes)
119+}
120+
114121 func convertCallResult(result CallResult) core.ToolResult {
115122 out := core.ToolResult{IsError: result.IsError}
116123 budget := &textBudget{}
@@ -359,6 +366,9 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
359366 type budgetedText struct {
360367 b strings.Builder
361368 budget textBudget
369+ // clipped is set by field when it shortened a string; the next write then
370+ // ends the output with the truncation marker.
371+ clipped bool
362372 }
363373
364374 func (t *budgetedText) full() bool { return t.budget.truncated }
@@ -368,9 +378,15 @@ func (t *budgetedText) remaining() int { return max(0, maxResultTextBytes-t.budg
368378
369379 // field prepares one server-supplied string for output: it is cut to the
370380 // remaining budget first (so a multi-megabyte description is never fully
371-// processed) and then stripped of control sequences and line breaks.
381+// processed) and then stripped of control sequences and line breaks. When the
382+// cut removes anything, the next write is marked truncated even if rounding to
383+// a rune boundary left the text within budget.
372384 func (t *budgetedText) field(s string) string {
373- return sanitizeText(clipUTF8(s, t.remaining()+1), false)
385+ clipped := clipUTF8(s, t.remaining()+1)
386+ if len(clipped) < len(s) {
387+ t.clipped = true
388+ }
389+ return sanitizeText(clipped, false)
374390 }
375391
376392 // clipUTF8 shortens s to at most n bytes without splitting a rune.
@@ -389,6 +405,10 @@ func (t *budgetedText) write(s string) {
389405 return
390406 }
391407 t.b.WriteString(t.budget.take(s))
408+ if t.clipped && !t.budget.truncated {
409+ t.b.WriteString(t.budget.forceTruncate())
410+ }
411+ t.clipped = false
392412 }
393413
394414 func (t *budgetedText) printf(format string, args ...any) {
@@ -111,6 +111,13 @@ func (b *textBudget) take(s string) string {
111 return s[:keep] + fmt.Sprintf("\n... [truncated at %d bytes]", maxResultTextBytes)111 return s[:keep] + fmt.Sprintf("\n... [truncated at %d bytes]", maxResultTextBytes)
112 }112 }
113 113
114+// forceTruncate ends the budget now and returns the truncation marker.
115+func (b *textBudget) forceTruncate() string {
116+ b.used = maxResultTextBytes
117+ b.truncated = true
118+ return fmt.Sprintf("\n... [truncated at %d bytes]", maxResultTextBytes)
119+}
120+
114 func convertCallResult(result CallResult) core.ToolResult {121 func convertCallResult(result CallResult) core.ToolResult {
115 out := core.ToolResult{IsError: result.IsError}122 out := core.ToolResult{IsError: result.IsError}
116 budget := &textBudget{}123 budget := &textBudget{}
@@ -359,6 +366,9 @@ func (t *listResourcesTool) Execute(ctx context.Context, args json.RawMessage, _
359 type budgetedText struct {366 type budgetedText struct {
360 b strings.Builder367 b strings.Builder
361 budget textBudget368 budget textBudget
369+ // clipped is set by field when it shortened a string; the next write then
370+ // ends the output with the truncation marker.
371+ clipped bool
362 }372 }
363 373
364 func (t *budgetedText) full() bool { return t.budget.truncated }374 func (t *budgetedText) full() bool { return t.budget.truncated }
@@ -368,9 +378,15 @@ func (t *budgetedText) remaining() int { return max(0, maxResultTextBytes-t.budg
368 378
369 // field prepares one server-supplied string for output: it is cut to the379 // field prepares one server-supplied string for output: it is cut to the
370 // remaining budget first (so a multi-megabyte description is never fully380 // remaining budget first (so a multi-megabyte description is never fully
371-// processed) and then stripped of control sequences and line breaks.381+// processed) and then stripped of control sequences and line breaks. When the
382+// cut removes anything, the next write is marked truncated even if rounding to
383+// a rune boundary left the text within budget.
372 func (t *budgetedText) field(s string) string {384 func (t *budgetedText) field(s string) string {
373- return sanitizeText(clipUTF8(s, t.remaining()+1), false)385+ clipped := clipUTF8(s, t.remaining()+1)
386+ if len(clipped) < len(s) {
387+ t.clipped = true
388+ }
389+ return sanitizeText(clipped, false)
374 }390 }
375 391
376 // clipUTF8 shortens s to at most n bytes without splitting a rune.392 // clipUTF8 shortens s to at most n bytes without splitting a rune.
@@ -389,6 +405,10 @@ func (t *budgetedText) write(s string) {
389 return405 return
390 }406 }
391 t.b.WriteString(t.budget.take(s))407 t.b.WriteString(t.budget.take(s))
408+ if t.clipped && !t.budget.truncated {
409+ t.b.WriteString(t.budget.forceTruncate())
410+ }
411+ t.clipped = false
392 }412 }
393 413
394 func (t *budgetedText) printf(format string, args ...any) {414 func (t *budgetedText) printf(format string, args ...any) {

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